/* HopAIO Dashboard - main app */
const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ---------- Icons (inline SVG) ----------
const I = {
  dash: (p) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><rect x="3" y="3" width="7" height="9" rx="1.5" /><rect x="14" y="3" width="7" height="5" rx="1.5" /><rect x="14" y="12" width="7" height="9" rx="1.5" /><rect x="3" y="16" width="7" height="5" rx="1.5" /></svg>,
  tasks: (p) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9h10M7 13h10M7 17h6" /></svg>,
  checkout: (p) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><path d="M3 5h2l2.4 11.2a2 2 0 002 1.6h7.8a2 2 0 002-1.6L21 8H6" /><circle cx="9" cy="21" r="1.5" /><circle cx="18" cy="21" r="1.5" /></svg>,
  monitor: (p) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><path d="M3 12h4l3-7 4 14 3-7h4" /></svg>,
  docs: (p) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><path d="M6 3h9l5 5v13a1 1 0 01-1 1H6a1 1 0 01-1-1V4a1 1 0 011-1z" /><path d="M14 3v6h6" /><path d="M9 13h7M9 17h5" /></svg>,
  settings: (p) => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z" /></svg>,
  search: (p) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" {...p}><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>,
  key: (p) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" {...p}><circle cx="7.5" cy="15.5" r="3.5" /><path d="M10 13l9-9M16 7l3 3M14 9l3 3" /></svg>,
  download: (p) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" {...p}><path d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" /></svg>,
  pause: (p) => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" {...p}><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>,
  play: (p) => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M6 4l14 8-14 8z" /></svg>,
  stop: (p) => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" {...p}><rect x="5" y="5" width="14" height="14" rx="1.5" /></svg>,
  refresh: (p) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" {...p}><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5" /></svg>,
  filter: (p) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" {...p}><path d="M4 5h16l-6 8v6l-4-2v-4z" /></svg>,
  copy: (p) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 012-2h10" /></svg>,
  ext: (p) => <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" {...p}><path d="M14 4h6v6M20 4l-9 9M19 13v6a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1h6" /></svg>
};

// ---------- Site catalog (real data from docs) ----------
const SITES = [
{ code: 'alza', name: 'Alza', region: 'CZ/DE/AT/HU', color: '#ff6900' },
{ code: 'mediaexpert', name: 'Mediaexpert', region: 'PL', color: '#e2061b' },
{ code: 'mediamarkt', name: 'MediaMarkt', region: 'NL', color: '#df0000' },
{ code: 'mediamarkt_de', name: 'MediaMarkt DE', region: 'DE', color: '#df0000' },
{ code: 'empik', name: 'Empik', region: 'PL', color: '#ed1c24' },
{ code: 'elbenwald', name: 'Elbenwald', region: 'DE/EU', color: '#7c5fff' },
{ code: 'basketballemotion', name: 'BasketballEmotion', region: 'ES/EU', color: '#ff8000' },
{ code: 'futbolemotion', name: 'FutbolEmotion', region: 'ES/EU', color: '#0064ff' },
{ code: 'mycomics', name: 'MyComics', region: 'IT', color: '#ffd400' },
{ code: 'fantasiastore', name: 'Fantasiastore', region: 'IT', color: '#9c27b0' },
{ code: 'gamesisland', name: 'Games Island', region: 'DE/EU', color: '#00bcd4' },
{ code: 'proshop', name: 'Proshop', region: 'DE/AT/NL/DK', color: '#27ae60' },
{ code: 'solebox', name: 'Solebox', region: 'DE/EU', color: '#ff4081' },
{ code: 'footshop', name: 'Footshop', region: 'CZ/EU', color: '#1a73e8' },
{ code: 'skatedeluxe', name: 'Skatedeluxe', region: 'DE/EU', color: '#f57c00' },
{ code: 'sportvision', name: 'Sportvision', region: 'CZ', color: '#e91e63' },
{ code: 'sportsshoes', name: 'SportsShoes', region: 'UK/EU', color: '#1976d2' },
{ code: 'frasers', name: 'Frasers (SD/Game/Flannels)', region: 'UK/EU', color: '#444' },
{ code: 'skstore', name: 'SK Store / WSS', region: 'PL/EU', color: '#00897b' },
{ code: 'zalando', name: 'Zalando', region: 'EU', color: '#ff6900' },
{ code: 'secretlair', name: 'Secret Lair', region: 'EU', color: '#e23b3b' },
{ code: 'lowkey', name: 'Lowkey', region: 'HU/CZ', color: '#888888' },
{ code: 'endclothing', name: 'END. Clothing', region: 'UK/EU', color: '#000000' },
{ code: 'travisscott', name: 'Travis Scott (DRAW)', region: 'US', color: '#b59169' }];


const siteByCode = (c) => SITES.find((s) => s.code === c) || { name: c, color: '#444', region: '—' };

// ---------- LOGIN ----------
// Normalize matches license_core.normalize_key() on the Python side: strip
// every non-alnum char and uppercase. The dashboard's JSON files are written
// at data/checkouts/<normalized>.json so the two halves agree on the path.
// Hosted dashboard backend. Defaults to the production host so the dashboard
// works when opened from disk or any other origin. Override per-deployment by
// setting window.HOP_API_BASE in an inline <script> in the HTML.
const API_BASE = (typeof window !== 'undefined' && window.HOP_API_BASE) || 'https://bohmannm.com';

async function api(path, opts = {}) {
  const { method = 'GET', body = null, token = null } = opts;
  const headers = { 'Accept': 'application/json' };
  if (body) headers['Content-Type'] = 'application/json';
  if (token) headers['Authorization'] = `Bearer ${token}`;
  const r = await fetch(`${API_BASE}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : null,
    cache: 'no-store',
  });
  if (r.status === 401 || r.status === 410) {
    // Token expired / key revoked — clear and force re-login.
    try { localStorage.removeItem('hop_key'); localStorage.removeItem('hop_token'); } catch (e) {}
    if (typeof window !== 'undefined') window.location.reload();
    throw new Error(`auth_${r.status}`);
  }
  if (!r.ok) throw new Error(`http_${r.status}`);
  if (r.status === 204) return null;
  return r.json();
}

function normalizeKey(s) {
  return (s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
function isValidKey(s) {
  const n = normalizeKey(s);
  return n.startsWith('HOP') && n.length === 19; // HOP + 16 alnum chars
}

function Login({ onAuth }) {
  const [key, setKey] = useState('');
  const [show, setShow] = useState(false);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  const submit = async (e) => {
    e.preventDefault();
    const raw = key.trim();
    if (!raw) { setErr('License key is required.'); return; }
    if (!isValidKey(raw)) {
      setErr('Invalid format. Expected HOP-XXXX-XXXX-XXXX-XXXX.');
      return;
    }
    setErr('');
    setBusy(true);
    try {
      const resp = await api('/v1/dashboard/login', {
        method: 'POST', body: { key: raw },
      });
      onAuth(normalizeKey(resp.key || raw), resp.token);
    } catch (e) {
      const msg = String(e && e.message || e);
      if (msg.includes('http_404')) setErr('Unknown license key.');
      else if (msg.includes('http_410')) setErr('Key revoked or expired.');
      else if (msg.includes('http_503') || msg.includes('Failed to fetch'))
        setErr('License server unreachable.');
      else setErr(`Login failed (${msg}).`);
    } finally {
      setBusy(false);
    }
  };

  return (
    <div className="login-wrap">
      <div className="grid-bg"></div>
      <div className="corner tl"></div>
      <div className="corner tr"></div>
      <div className="corner bl"></div>
      <div className="corner br"></div>
      <form className="login-card" onSubmit={submit}>
        <div className="login-logo">
          <img src="assets/logo.png" alt="HopAIO" />
          <div>
            <div className="name">HopAIO</div>
            <div style={{ fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.3em', color: 'var(--dim)', textTransform: 'uppercase' }}>all · in · one · bot</div>
          </div>
        </div>
        <div className="login-tag">private distribution v2.4.1</div>

        <h2 className="login-title">Authenticate</h2>
        <p className="login-sub">Enter your license key to access the dashboard.</p>

        <label htmlFor="key">License Key</label>
        <div className="input-wrap">
          <span className="input-icon"><I.key /></span>
          <input
            id="key"
            className="input"
            type={show ? 'text' : 'password'}
            placeholder="HOP-XXXX-XXXX-XXXX-XXXX"
            value={key}
            onChange={(e) => setKey(e.target.value)}
            autoFocus />
          
          <button type="button" className="input-toggle" onClick={() => setShow((s) => !s)}>{show ? 'HIDE' : 'SHOW'}</button>
        </div>
        <div className="error-msg">{err}</div>

        <button className="btn" disabled={busy}>
          {busy ? 'VERIFYING…' : 'AUTHENTICATE'}
        </button>

        <div className="login-meta">
          <span className="status-pill">SERVER ONLINE</span>
        </div>
      </form>
    </div>);

}

// ---------- SIDEBAR ----------
function Sidebar({ view, setView, licenseKey, onLogout }) {
  const items = [
  { id: 'dashboard', label: 'Dashboard', icon: I.dash },
  { id: 'tasks', label: 'Tasks', icon: I.tasks },
  { id: 'checkout', label: 'Checkout Log', icon: I.checkout, badge: 'LIVE' },
  { id: 'monitor', label: 'Monitoring', icon: I.monitor },
  { id: 'docs', label: 'Docs', icon: I.docs },
  { id: 'settings', label: 'Settings', icon: I.settings }];

  const maskedKey = licenseKey ? `${licenseKey.slice(0, 5)}····${licenseKey.slice(-4)}` : '—';
  return (
    <aside className="sidebar">
      <div className="brand">
        <img src="assets/logo.png" alt="" />
        <div>
          <div className="t" style={{ fontSize: "18px" }}>HopAIO</div>
          <div className="v"></div>
        </div>
      </div>
      <div className="nav-section">Operate</div>
      {items.slice(0, 4).map((it) =>
      <div key={it.id} className={`nav-item ${view === it.id ? 'active' : ''}`} onClick={() => setView(it.id)}>
          <span className="ico"><it.icon /></span>
          <span>{it.label}</span>
          {it.badge && <span className="badge">{it.badge}</span>}
        </div>
      )}
      <div className="nav-section">Resources</div>
      {items.slice(4).map((it) =>
      <div key={it.id} className={`nav-item ${view === it.id ? 'active' : ''}`} onClick={() => setView(it.id)}>
          <span className="ico"><it.icon /></span>
          <span>{it.label}</span>
        </div>
      )}
      <div className="nav-item" onClick={() => { window.location.href = '/v1/download/template'; }}>
        <span className="ico"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg></span>
        <span>Download Bot</span>
        <span className="badge">EXE</span>
      </div>
      <div className="nav-spacer"></div>
      <div className="nav-foot">
        <div className="user-row">
          <div className="user-avatar">A</div>
          <div>
            <div className="user-name">licensee</div>
            <div className="user-key">{maskedKey}</div>
          </div>
        </div>
        <div className="nav-item" onClick={onLogout}>
          <span className="ico"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg></span>
          <span>Sign out</span>
        </div>
      </div>
    </aside>);

}

// ---------- TOPBAR ----------
function Topbar({ crumbs, right }) {
  return (
    <div className="topbar">
      <div className="crumbs">
        {crumbs.map((c, i) =>
        <React.Fragment key={i}>
            {i > 0 && <span className="sep">/</span>}
            <span className={i === crumbs.length - 1 ? 'current' : ''}>{c}</span>
          </React.Fragment>
        )}
      </div>
      <div className="top-actions">{right}</div>
    </div>);

}

// ---------- DASHBOARD VIEW ----------
function DashboardView({ tasks, checkouts, monitors }) {
  const activeMonitors = (monitors || []).reduce((s, m) => s + (m.links || 0), 0);
  const success = checkouts.filter((c) => c.status === 'success').length;
  const failed = checkouts.filter((c) => c.status === 'failed').length;
  const total = checkouts.reduce((s, c) => s + (c.status === 'success' ? (Number(c.price) || 0) : 0), 0);

  return (
    <div className="view">
      <Topbar
        crumbs={['HopAIO', 'Overview']}
        right={<>
          <button className="btn ghost" style={{ width: 'auto', margin: 0, padding: '8px 14px' }}><I.refresh /> SYNC</button>
        </>} />
      
      <h1 className="page">Dashboard</h1>
      <p className="page-sub">Live overview of your tasks running on the HopAIO server.</p>

      <div className="stats">
        <div className="stat">
          <div className="lbl">Active monitors</div>
          <div className="val">{activeMonitors}</div>
        </div>
        <div className="stat">
          <div className="lbl">Successful checkouts (24h)</div>
          <div className="val">{success}</div>
        </div>
        <div className="stat">
          <div className="lbl">Failed / declined</div>
          <div className="val">{failed}</div>
        </div>
        <div className="stat">
          <div className="lbl">Total spend (24h)</div>
          <div className="val">€ {total.toFixed(0)}</div>
        </div>
      </div>

      <div className="split">
        <div className="card" style={{ gridColumn: '1 / -1' }}>
          <div className="card-head">
            <h3>Recent Checkouts</h3>
            <div className="actions">
              <span className="tab active">ALL</span>
              <span className="tab">SUCCESS</span>
              <span className="tab">FAILED</span>
            </div>
          </div>
          <div className="checkouts">
            {checkouts.slice(0, 8).map((c) => <CheckoutItem key={c.id} c={c} />)}
          </div>
        </div>
      </div>
    </div>);

}

function CheckoutItem({ c }) {
  const s = siteByCode(c.site);
  return (
    <div className="co-item">
      <div className="co-thumb" style={{ background: `linear-gradient(135deg, ${s.color}25, transparent)` }}></div>
      <div>
        <div className="co-title">{c.product}</div>
        <div className="co-meta">
          <span style={{ color: s.color }}>{s.name}</span>
          <span className="sep">·</span>
          SIZE {c.size}
          <span className="sep">·</span>
          € {c.price}
          <span className="sep">·</span>
          {c.profile}
        </div>
      </div>
      <div className={`co-status ${c.status}`}>{c.status}</div>
    </div>);

}

function Th({ children }) {
  return <th style={{ textAlign: 'left', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', fontWeight: 500 }}>{children}</th>;
}

function CheckBox({ checked, indeterminate, onChange }) {
  const ref = useRef(null);
  useEffect(() => {if (ref.current) ref.current.indeterminate = !!indeterminate;}, [indeterminate]);
  return (
    <label style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
      <input ref={ref} type="checkbox" checked={!!checked} onChange={onChange} style={{ display: 'none' }} />
      <span style={{
        width: 16, height: 16, borderRadius: 4,
        border: `1.5px solid ${checked || indeterminate ? 'var(--green)' : 'var(--line-2)'}`,
        background: checked || indeterminate ? 'var(--green)' : 'transparent',
        display: 'grid', placeItems: 'center',
        boxShadow: checked || indeterminate ? '0 0 8px var(--green-glow)' : 'none',
        transition: 'all .12s'
      }}>
        {checked && <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="#02110a" strokeWidth="2.4"><path d="M2 6l3 3 5-6" /></svg>}
        {indeterminate && !checked && <span style={{ width: 8, height: 2, background: '#02110a' }}></span>}
      </span>
    </label>);

}

function StatusBadge({ status }) {
  const map = {
    running: { label: 'RUNNING', color: 'var(--green)', bg: 'var(--green-soft)' },
    monitoring: { label: 'MONITORING', color: 'var(--blue)', bg: 'rgba(93,213,255,0.08)' },
    stopped: { label: 'STOPPED', color: 'var(--muted)', bg: 'rgba(111,128,121,0.1)' },
    error: { label: 'ERROR', color: 'var(--red)', bg: 'rgba(255,93,93,0.1)' },
    queued: { label: 'QUEUED', color: 'var(--amber)', bg: 'rgba(240,180,41,0.1)' },
    success: { label: 'CHECKED OUT', color: 'var(--green)', bg: 'var(--green-soft)' }
  };
  const m = map[status] || map.stopped;
  return <span style={{ fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.14em', padding: '3px 8px', borderRadius: 4, color: m.color, background: m.bg, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
    <span style={{ width: 6, height: 6, borderRadius: '50%', background: m.color, boxShadow: status === 'running' || status === 'monitoring' ? `0 0 6px ${m.color}` : 'none', animation: status === 'running' || status === 'monitoring' ? 'blink 1.6s infinite' : 'none' }}></span>
    {m.label}
  </span>;
}

function SiteSelect({ value, onChange, sitesUsed }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const h = (e) => {if (ref.current && !ref.current.contains(e.target)) setOpen(false);};
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);
  const current = value === 'all' ? 'ALL SITES' : siteByCode(value).name.toUpperCase();
  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <span className={`filter-pill ${value !== 'all' ? 'active' : ''}`} style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)}>
        <I.filter /> {current} <span style={{ color: 'var(--dim)' }}>▾</span>
      </span>
      {open &&
      <div style={{ position: 'absolute', top: 'calc(100% + 6px)', left: 0, background: 'var(--bg-2)', border: '1px solid var(--line-2)', borderRadius: 8, padding: 6, minWidth: 240, maxHeight: 340, overflowY: 'auto', zIndex: 10, boxShadow: '0 16px 40px -12px rgba(0,0,0,0.7)' }}>
          <div style={{ padding: '6px 10px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', color: 'var(--dim)', textTransform: 'uppercase' }}>SITE FILTER</div>
          <div className={`nav-item ${value === 'all' ? 'active' : ''}`} style={{ padding: '6px 10px', fontSize: 12 }} onClick={() => {onChange('all');setOpen(false);}}>
            <span>All sites</span>
            <span className="badge">{sitesUsed.reduce((s, [, n]) => s + n, 0)}</span>
          </div>
          {SITES.map((s) => {
          const used = sitesUsed.find(([c]) => c === s.code);
          return (
            <div key={s.code} className={`nav-item ${value === s.code ? 'active' : ''}`} style={{ padding: '6px 10px', fontSize: 12 }} onClick={() => {onChange(s.code);setOpen(false);}}>
                <span style={{ width: 8, height: 8, borderRadius: 2, background: s.color, boxShadow: `0 0 6px ${s.color}80` }}></span>
                <span>{s.name}</span>
                <span style={{ marginLeft: 'auto', fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)' }}>{used ? used[1] : 0}</span>
              </div>);

        })}
        </div>
      }
    </div>);

}

// ---------- LIVE LOG (used in dashboard + checkout view) ----------
function LiveLog({ tasks, compact }) {
  const [lines, setLines] = useState(() => seedLogs(tasks).slice(0, compact ? 14 : 60));
  useEffect(() => {
    const id = setInterval(() => {
      setLines((prev) => [makeLogLine(tasks), ...prev].slice(0, compact ? 14 : 200));
    }, 1800);
    return () => clearInterval(id);
  }, [tasks, compact]);

  return (
    <div className="log" style={compact ? { maxHeight: 466 } : {}}>
      {lines.map((l) =>
      <div className="row" key={l.id}>
          <span className="ts">{l.ts}</span>
          <span className={`lvl ${l.lvl}`}>{l.lvl}</span>
          <span className="site" style={{ color: siteByCode(l.site).color }}>{siteByCode(l.site).name.toUpperCase()}</span>
          {/* P3 XSS audit: msg was previously injected as raw HTML so the
              <b>/<code> tags in the log templates would render. Tasks
              data could (in a future wiring) flow from server-side
              checkouts / qt input — user-controlled — so we now render
              as plain text via React. Templates will show literal tags;
              LiveLog is not currently mounted so no UI impact today. */}
          <span className="msg">{l.msg}</span>
        </div>
      )}
    </div>);

}

function makeLogLine(tasks) {
  const samples = [
  { lvl: 'success', tpl: (t) => `Checkout <b>SUCCESS</b> · ${t.product} · €${t.price} · <code>order #${rand(100000, 999999)}</code>` },
  { lvl: 'info', tpl: (t) => `Stock detected · ${t.product} · sizes <code>${t.sizes.join(', ')}</code>` },
  { lvl: 'info', tpl: (t) => `Adding to cart · ${t.product} · <code>${t.sizes[0]}</code>` },
  { lvl: 'info', tpl: (t) => `Polling <code>${t.sku}</code> · ${rand(40, 250)}ms` },
  { lvl: 'warn', tpl: (t) => `Queue position <code>#${rand(80, 4200)}</code> · ETA ${rand(1, 12)}m` },
  { lvl: 'warn', tpl: (t) => `Captcha challenge · solver <code>${pick(['capmonster', '2captcha', 'manual'])}</code>` },
  { lvl: 'error', tpl: (t) => `Payment declined · 3DS timeout · profile <code>${t.profile}</code>` },
  { lvl: 'info', tpl: (t) => `Session refreshed · cookies ok` }];

  const t = pick(tasks);
  const s = pick(samples);
  return { id: Math.random().toString(36).slice(2), ts: nowTs(), lvl: s.lvl, site: t.site, msg: s.tpl(t) };
}
function seedLogs(tasks) {
  const out = [];
  for (let i = 0; i < 30; i++) out.push(makeLogLine(tasks));
  return out;
}
function nowTs() {
  const d = new Date();
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
}
function rand(a, b) {return Math.floor(a + Math.random() * (b - a));}
function pick(arr) {return arr[Math.floor(Math.random() * arr.length)];}

// ---------- CHECKOUT LOG VIEW ----------
function CheckoutView({ tasks, checkouts, onRefresh, licenseKey }) {
  const [filter, setFilter] = useState('all');
  const filtered = filter === 'all' ? checkouts : checkouts.filter((c) => c.status === filter);

  const exportCsv = () => {
    if (!checkouts.length) return;
    const header = ['ts', 'site', 'product', 'size', 'price', 'currency', 'profile', 'method', 'mode', 'order_id', 'status'];
    const esc = (v) => {
      const s = v == null ? '' : String(v);
      return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    };
    const lines = [header.join(',')].concat(checkouts.map((c) => header.map((k) => esc(c[k])).join(',')));
    const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `hopaio-checkouts-${licenseKey || 'export'}.csv`;
    document.body.appendChild(a); a.click(); a.remove();
    URL.revokeObjectURL(url);
  };

  return (
    <div className="view">
      <Topbar
        crumbs={['HopAIO', 'Checkout Log']}
        right={<>
          <span className="chip ok"><span className="dot"></span>{checkouts.filter((c) => c.status === 'success').length} TODAY</span>
          <button className="btn ghost" style={{ width: 'auto', margin: 0, padding: '8px 14px' }} onClick={onRefresh}><I.refresh /> REFRESH</button>
          <button className="btn ghost" style={{ width: 'auto', margin: 0, padding: '8px 14px' }} onClick={exportCsv} disabled={!checkouts.length}><I.download /> EXPORT</button>
        </>} />

      <h1 className="page">Checkout Log</h1>
      <p className="page-sub">Per-license history written by our servers. Updates whenever a task closes.</p>

      <div className="card">
        <div className="card-head">
          <h3>Checkouts</h3>
          <div className="actions">
            <span className={`tab ${filter === 'all' ? 'active' : ''}`} onClick={() => setFilter('all')}>ALL</span>
            <span className={`tab ${filter === 'success' ? 'active' : ''}`} onClick={() => setFilter('success')}>SUCCESS</span>
            <span className={`tab ${filter === 'failed' ? 'active' : ''}`} onClick={() => setFilter('failed')}>FAILED</span>
            <span className={`tab ${filter === 'pending' ? 'active' : ''}`} onClick={() => setFilter('pending')}>PENDING</span>
          </div>
        </div>
        <div className="checkouts" style={{ maxHeight: 'none' }}>
          {filtered.length === 0
            ? <div style={{ padding: '40px 18px', textAlign: 'center', color: 'var(--muted)', fontFamily: 'var(--mono)', fontSize: 12, letterSpacing: '0.16em' }}>
                — NO CHECKOUTS YET FOR THIS LICENSE —
              </div>
            : filtered.map((c) => <CheckoutItem key={c.id} c={c} />)}
        </div>
      </div>
    </div>);

}

// ---------- MONITORING VIEW ----------
function MonitorView({ monitors, setMonitors, qtConnected, listenerLinks, onRefreshListener, onAddLink, onDeleteLink, onRefresh }) {
  const [editing, setEditing] = useState(null); // site code being edited
  const editor = monitors.find((m) => m.site === editing);

  // Persist via the server. Optimistic UI: card immediately shows pending
  // status; the next reloadMonitors() reflects the canonical state.
  const save = async (patch) => {
    if (patch && patch.url) {
      try {
        await onAddLink({ site: editing, url: patch.url, region: patch.region || null });
      } catch (e) {
        alert(`Add failed: ${e.message || e}`);
      }
    }
  };

  return (
    <div className="view">
      <Topbar
        crumbs={['HopAIO', 'Monitoring']}
        right={<>
          {qtConnected
            ? <span className="chip ok"><span className="dot"></span>QT LISTENER · CONNECTED</span>
            : <span className="chip" style={{ color: 'var(--red)', borderColor: 'rgba(255,93,93,0.3)' }}><span className="dot" style={{ background: 'var(--red)', boxShadow: '0 0 8px rgba(255,93,93,0.5)', animation: 'none' }}></span>QT LISTENER · OFFLINE</span>}
          <span className="chip">{monitors.reduce((s, m) => s + (m.links || 0), 0)} LINKS</span>
          <button className="btn ghost" style={{ width: 'auto', margin: 0, padding: '8px 14px' }} onClick={onRefreshListener}><I.refresh /> REFRESH</button>
        </>} />
      
      <h1 className="page">Proxyless Monitoring</h1>
      <p className="page-sub">Direct-connection monitors running on our servers. No proxies in use — pure server IP.</p>

      <div className="monitor-grid">
        {monitors.map((m) => {
          const s = siteByCode(m.site);
          const empty = !m.links || m.links === 0;
          if (empty) {
            return (
              <div
                key={m.site}
                onClick={() => setEditing(m.site)}
                style={{
                  cursor: 'pointer', minHeight: 200, padding: 20,
                  background: 'var(--green-soft)',
                  border: '2px dashed rgba(255, 77, 157, 0.4)',
                  borderRadius: 8, display: 'flex', flexDirection: 'column',
                  alignItems: 'center', justifyContent: 'center', gap: 12,
                  transition: 'all 0.15s',
                }}
                onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255, 77, 157, 0.12)'; e.currentTarget.style.borderColor = 'rgba(255, 77, 157, 0.7)'; }}
                onMouseLeave={e => { e.currentTarget.style.background = 'var(--green-soft)'; e.currentTarget.style.borderColor = 'rgba(255, 77, 157, 0.4)'; }}
              >
                <div className="mon-icon" style={{ color: s.color, borderColor: s.color + '60', fontSize: 16 }}>{s.name.slice(0, 2).toUpperCase()}</div>
                <div style={{ textAlign: 'center' }}>
                  <div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>{s.name}</div>
                  <div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '0.1em' }}>{s.region}</div>
                </div>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', background: 'rgba(255, 77, 157, 0.18)', border: '1px solid rgba(255, 77, 157, 0.5)', borderRadius: 6, color: 'var(--green)', fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 600, letterSpacing: '0.16em' }}>
                  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M12 5v14M5 12h14"/></svg>
                  ADD LINK
                </div>
              </div>);
          }
          return (
            <div key={m.site} className={`mon-card ${m.live ? 'live' : ''}`}>
              <div className="mon-head">
                <div className="mon-icon" style={{ color: s.color, borderColor: s.color + '40' }}>{s.name.slice(0, 2).toUpperCase()}</div>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div className="mon-name">{s.name}{m.region ? <span style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--green)', marginLeft: 8, padding: '1px 6px', border: '1px solid rgba(255, 77, 157,0.3)', borderRadius: 3, letterSpacing: '0.12em' }}>{m.region}</span> : null}</div>
                  <div className="mon-region">{s.region} · {m.links} link{m.links === 1 ? '' : 's'}</div>
                </div>
              </div>
              {m.url &&
              <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: '#07100c', border: '1px solid var(--line-2)', borderRadius: 4 }}>
                  <span style={{ flex: 1, fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '0.02em' }}>{m.url}</span>
                  {m.urlStatus === 'active' && <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--green)', letterSpacing: '0.14em', padding: '2px 6px', background: 'var(--green-soft)', border: '1px solid rgba(255, 77, 157,0.3)', borderRadius: 3 }}>ACTIVE</span>}
                  {m.urlStatus === 'pending' && <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--amber)', letterSpacing: '0.14em', padding: '2px 6px', background: 'rgba(240,180,41,0.08)', border: '1px solid rgba(240,180,41,0.25)', borderRadius: 3 }}>PENDING</span>}
                </div>
              }
              <div className="mon-stats">
                <div><div className="k">Links</div><div className="v green">{m.links}</div></div>
                {m.priceLimit ?
                <div><div className="k">Price limit</div><div className="v" style={{ fontSize: 13, color: 'var(--green)', fontFamily: 'var(--mono)' }}>≤ {m.priceLimit.value} {m.priceLimit.currency}</div></div> :
                <div><div className="k">Last restock</div><div className="v" style={{ fontSize: 12 }}>{m.lastRestock}</div></div>}
              </div>
              <button
                onClick={() => setEditing(m.site)}
                style={{
                  width: '100%', marginTop: 10, padding: '10px 12px',
                  background: 'transparent',
                  border: '1px dashed rgba(255, 77, 157, 0.35)',
                  borderRadius: 6, cursor: 'pointer',
                  fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.14em',
                  color: 'var(--green)',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
                  transition: 'all 0.15s',
                }}
                onMouseEnter={e => { e.currentTarget.style.background = 'var(--green-soft)'; e.currentTarget.style.borderColor = 'rgba(255, 77, 157, 0.6)'; }}
                onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'rgba(255, 77, 157, 0.35)'; }}
              >
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14"/></svg>
                ADD ANOTHER LINK
              </button>
              <div className="mon-foot" style={{ marginTop: 8 }}>
                <span style={{ color: m.live ? 'var(--green)' : 'var(--muted)' }}>{m.live ? '● LIVE' : '○ IDLE'}</span>
              </div>
            </div>);

        })}
      </div>

      {editor && <MonitorEditModal monitor={editor} site={siteByCode(editor.site)} onClose={() => setEditing(null)} onSave={save} />}

      <Watchlist monitors={monitors} onDeleteLink={onDeleteLink} />
    </div>);

}

// QT CSV — products already known/approved by the operator. Hydrated from
// the license server's /v1/dashboard/qt-links endpoint (which serves the
// list this license's bot uploaded after its last build_qt_links_json).
// Empty until App calls hydrateQtCsv(token) post-login.
const QT_CSV = new Set();

async function hydrateQtCsv(token) {
  if (!token) return;
  try {
    const data = await api('/v1/dashboard/qt-links', { token });
    if (!Array.isArray(data)) return;
    QT_CSV.clear();
    data.forEach((s) => { if (typeof s === 'string' && s) QT_CSV.add(s.toLowerCase()); });
  } catch (e) { /* server miss → leave Set empty */ }
}

function qtMatch(url) {
  if (!url || url.length < 8) return null;
  const u = url.toLowerCase();
  for (const k of QT_CSV) if (u.includes(k)) return k;
  return null;
}

function Watchlist({ monitors, onDeleteLink }) {
  const [siteFilter, setSiteFilter] = useState('all');
  const [statusFilter, setStatusFilter] = useState('all');
  const [q, setQ] = useState('');

  const all = useMemo(() => {
    const out = [];
    monitors.forEach(m => (m.watchlist || []).forEach(w => out.push({ ...w, site: m.site })));
    return out.sort((a, b) => b.addedAt - a.addedAt);
  }, [monitors]);

  const filtered = all.filter(w => {
    if (siteFilter !== 'all' && w.site !== siteFilter) return false;
    if (statusFilter !== 'all' && w.urlStatus !== statusFilter) return false;
    if (q && !w.url.toLowerCase().includes(q.toLowerCase())) return false;
    return true;
  });

  const remove = async (siteCode, id) => {
    if (!confirm('Remove this monitored URL?')) return;
    try {
      await onDeleteLink(id);
    } catch (e) {
      alert(`Delete failed: ${e.message || e}`);
    }
  };
  const sitesUsed = useMemo(() => {
    const c = {}; all.forEach(w => { c[w.site] = (c[w.site] || 0) + 1; });
    return Object.entries(c).sort((a, b) => b[1] - a[1]);
  }, [all]);

  const ago = (ts) => {
    const s = Math.floor((Date.now() - ts) / 1000);
    if (s < 60) return s + 's ago';
    if (s < 3600) return Math.floor(s / 60) + 'm ago';
    if (s < 86400) return Math.floor(s / 3600) + 'h ago';
    return Math.floor(s / 86400) + 'd ago';
  };

  return (
    <div className="card" style={{marginTop:24}}>
      <div className="card-head">
        <h3>Watchlist <span style={{color:'var(--muted)', fontFamily:'var(--mono)', fontSize:11, marginLeft:8, letterSpacing:'0.1em'}}>{all.length} ITEMS</span></h3>
        <div className="actions">
          <span className={`tab ${statusFilter==='all'?'active':''}`} onClick={()=>setStatusFilter('all')}>ALL</span>
          <span className={`tab ${statusFilter==='active'?'active':''}`} onClick={()=>setStatusFilter('active')}>ACTIVE</span>
          <span className={`tab ${statusFilter==='pending'?'active':''}`} onClick={()=>setStatusFilter('pending')}>PENDING</span>
        </div>
      </div>
      <div className="log-toolbar">
        <div className="search-wrap">
          <I.search />
          <input className="search" placeholder="Filter by URL…" value={q} onChange={e=>setQ(e.target.value)} style={{width:'100%'}} />
        </div>
        <SiteSelect value={siteFilter} onChange={setSiteFilter} sitesUsed={sitesUsed} />
      </div>
      <div style={{overflowX:'auto'}}>
        <table style={{width:'100%', borderCollapse:'collapse', fontSize:13}}>
          <thead>
            <tr style={{borderBottom:'1px solid var(--line)'}}>
              <Th>Site</Th>
              <Th>Monitored URL</Th>
              <Th>Status</Th>
              <Th>Added</Th>
              <Th></Th>
            </tr>
          </thead>
          <tbody>
            {filtered.map(w => {
              const s = siteByCode(w.site);
              return (
                <tr key={w.id} style={{borderBottom:'1px solid rgba(26,38,32,0.5)'}}>
                  <td style={{padding:'10px 12px 10px 18px'}}>
                    <div style={{display:'flex', alignItems:'center', gap:8}}>
                      <span style={{width:8, height:8, borderRadius:2, background:s.color, boxShadow:`0 0 6px ${s.color}80`}}></span>
                      <span style={{fontSize:12}}>{s.name}</span>
                    </div>
                  </td>
                  <td style={{padding:'10px 12px', fontFamily:'var(--mono)', fontSize:11, color:'var(--text)', maxWidth:480, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={w.url}>{w.url}</td>
                  <td style={{padding:'10px 12px'}}>
                    {w.urlStatus === 'active'
                      ? <span style={{fontFamily:'var(--mono)', fontSize:10, color:'var(--green)', letterSpacing:'0.14em', padding:'3px 8px', background:'var(--green-soft)', border:'1px solid rgba(255, 77, 157,0.3)', borderRadius:4}}>ACTIVE</span>
                      : <span style={{fontFamily:'var(--mono)', fontSize:10, color:'var(--amber)', letterSpacing:'0.14em', padding:'3px 8px', background:'rgba(240,180,41,0.08)', border:'1px solid rgba(240,180,41,0.25)', borderRadius:4}}>PENDING</span>}
                  </td>
                  <td style={{padding:'10px 12px', fontFamily:'var(--mono)', fontSize:11, color:'var(--muted)'}}>{ago(w.addedAt)}</td>
                  <td style={{padding:'10px 18px 10px 12px'}}>
                    <div style={{display:'flex', gap:4, justifyContent:'flex-end'}}>
                      <button className="icon-btn" title="Remove from watchlist" onClick={() => remove(w.site, w.id)} style={{borderColor:'rgba(255,93,93,0.25)', color:'var(--red)'}}>
                        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M3 6h18M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2M6 6l1 14a1 1 0 001 1h8a1 1 0 001-1l1-14M10 11v6M14 11v6"/></svg>
                      </button>
                    </div>
                  </td>
                </tr>
              );
            })}
            {filtered.length === 0 && (
              <tr><td colSpan="5" style={{padding:'30px 18px', textAlign:'center', color:'var(--muted)', fontFamily:'var(--mono)', fontSize:12, letterSpacing:'0.1em'}}>NO ITEMS MATCH</td></tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function MonitorEditModal({ monitor, site, onClose, onSave }) {
  const [url, setUrl] = useState(monitor.url || '');
  const [region, setRegion] = useState(monitor.region);
  const [priceLimit, setPriceLimit] = useState(monitor.priceLimit ? monitor.priceLimit.value : '');
  const match = qtMatch(url);
  const valid = url.trim().length > 8;
  const submit = (e) => {
    e.preventDefault();
    if (!valid) return;
    const next = { url: url.trim(), region, urlStatus: match ? 'active' : 'pending', submittedAt: Date.now() };
    if (monitor.priceLimit) next.priceLimit = { ...monitor.priceLimit, value: Number(priceLimit) || monitor.priceLimit.value };
    onSave(next);
    onClose();
  };
  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(2,8,5,0.7)', backdropFilter: 'blur(4px)', display: 'grid', placeItems: 'center', zIndex: 50 }}>
      <form onClick={(e) => e.stopPropagation()} onSubmit={submit} style={{ width: '100%', maxWidth: 520, background: 'var(--bg-1)', border: '1px solid var(--line-2)', borderRadius: 12, padding: '24px 26px', boxShadow: '0 30px 80px -20px rgba(0,0,0,0.8), 0 0 60px -20px var(--green-glow)' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
          <div className="mon-icon" style={{ color: site.color, borderColor: site.color + '40' }}>{site.name.slice(0, 2).toUpperCase()}</div>
          <div>
            <div style={{ fontSize: 16, fontWeight: 500 }}>{site.name}</div>
            <div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)', letterSpacing: '0.18em', textTransform: 'uppercase' }}>EDIT MONITOR</div>
          </div>
          <button type="button" onClick={onClose} className="icon-btn" style={{ marginLeft: 'auto' }}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l12 12M18 6L6 18" /></svg>
          </button>
        </div>

        {monitor.regions &&
        <div style={{ marginBottom: 16 }}>
            <label>Region</label>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {monitor.regions.map((r) =>
            <span key={r} className={`filter-pill ${region === r ? 'active' : ''}`} style={{ cursor: 'pointer' }} onClick={() => setRegion(r)}>{r}</span>
            )}
            </div>
          </div>
        }

        <label htmlFor="mon-url">Monitor link</label>
        <div className="input-wrap">
          <span className="input-icon">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><path d="M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1" /></svg>
          </span>
          <input
            id="mon-url"
            className="input"
            type="url"
            placeholder={`https://${monitor.site}.com/product/...`}
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            autoFocus />
          
        </div>
        <p style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '0.04em', marginTop: 8, marginBottom: 0 }}>
          Direct product URL, category page, or keyword search. The monitor matches against the QT CSV — known products activate instantly, new products go pending until approved.
        </p>

        {monitor.priceLimit &&
        <div style={{ marginTop: 16 }}>
            <label htmlFor="mon-price">Price limit <span style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)', letterSpacing: '0.14em', marginLeft: 6 }}>· {monitor.priceLimit.currency}</span></label>
            <div className="input-wrap">
              <span className="input-icon" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--green)', letterSpacing: 0 }}>≤</span>
              <input id="mon-price" className="input" type="number" min="0" step="1" placeholder={String(monitor.priceLimit.value)} value={priceLimit} onChange={(e) => setPriceLimit(e.target.value)} />
              <span style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)', fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)', letterSpacing: '0.14em' }}>{monitor.priceLimit.currency}</span>
            </div>
            <p style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '0.04em', marginTop: 8, marginBottom: 0 }}>
              Skip restocks priced above this value. {monitor.priceLimit.hint}.
            </p>
          </div>
        }

        {/* QT match feedback */}
        {valid && (
        match ?
        <div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 6, background: 'var(--green-soft)', border: '1px solid rgba(255, 77, 157,0.3)', display: 'flex', alignItems: 'center', gap: 10, fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--green)', letterSpacing: '0.04em' }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><circle cx="12" cy="12" r="9" /><path d="M8 12l3 3 5-6" /></svg>
              <span>MATCHED IN QT CSV · <span style={{ color: 'var(--text)' }}>{match}</span> — will activate instantly.</span>
            </div> :

        <div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 6, background: 'rgba(240,180,41,0.08)', border: '1px solid rgba(240,180,41,0.25)', display: 'flex', alignItems: 'center', gap: 10, fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--amber)', letterSpacing: '0.04em' }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 21h20z" /><path d="M12 9v5M12 17v.5" stroke="#0b0f0d" /></svg>
              <span>NOT IN QT CSV · will be submitted for approval (pending).</span>
            </div>)

        }

        <div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
          <button type="button" className="btn ghost" style={{ margin: 0, flex: 1 }} onClick={onClose}>CANCEL</button>
          <button type="submit" className="btn" style={{ margin: 0, flex: 1 }} disabled={!valid}>
            {!valid ? 'ENTER URL' : match ? 'ADD INSTANTLY' : 'SUBMIT FOR REVIEW'}
          </button>
        </div>
      </form>
    </div>);

}

// ---------- DOCS VIEW ----------
function DocsView() {
  const [section, setSection] = useState('getting-started');
  const builtins = {
    'getting-started': <GettingStarted />,
    'csv': <CsvDoc />,
    'proxies': <ProxiesDoc />,
    'payment-modes': <PaymentModesDoc />,
    'telemetry': <TelemetryDoc />,
    'sites': <SitesDoc onPick={setSection} />,
    'troubleshooting': <TroubleshootingDoc />
  };
  const body = builtins[section] || <SiteGuide code={section.replace('site:', '')} />;

  return (
    <div className="view">
      <Topbar
        crumbs={['HopAIO', 'Documentation']}
        right={<>
          <a className="chip" href="https://bohmaan.github.io/HopAIO-docs/" target="_blank" rel="noopener" style={{ textDecoration: 'none' }}>
            FULL DOCS <I.ext />
          </a>
        </>} />
      
      <h1 className="page">Documentation</h1>
      <p className="page-sub">Embedded reference. Synced with the public docs at bohmaan.github.io/HopAIO-docs.</p>

      <div className="docs-layout">
        <div className="doc-side" style={{ maxHeight: 'calc(100vh - 80px)', overflowY: 'auto' }}>
          <div className="grp">Getting Started</div>
          <a className={`doc-link ${section === 'getting-started' ? 'active' : ''}`} onClick={() => setSection('getting-started')}>Installation</a>
          <div className="grp">Configuration</div>
          <a className={`doc-link ${section === 'csv' ? 'active' : ''}`} onClick={() => setSection('csv')}>Tasks CSV</a>
          <a className={`doc-link ${section === 'proxies' ? 'active' : ''}`} onClick={() => setSection('proxies')}>Proxies</a>
          <a className={`doc-link ${section === 'payment-modes' ? 'active' : ''}`} onClick={() => setSection('payment-modes')}>Payment modes</a>
          <a className={`doc-link ${section === 'telemetry' ? 'active' : ''}`} onClick={() => setSection('telemetry')}>Telemetry & webhooks</a>
          <a className={`doc-link ${section === 'sites' ? 'active' : ''}`} onClick={() => setSection('sites')}>All sites</a>
          <div className="grp">Site Guides</div>
          {SITES.map((s) =>
          <a key={s.code} className={`doc-link ${section === 'site:' + s.code ? 'active' : ''}`} onClick={() => setSection('site:' + s.code)} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <span style={{ width: 6, height: 6, borderRadius: '50%', background: s.color, flexShrink: 0 }}></span>
              <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.name}</span>
            </a>
          )}
          <div className="grp">Help</div>
          <a className={`doc-link ${section === 'troubleshooting' ? 'active' : ''}`} onClick={() => setSection('troubleshooting')}>Troubleshooting</a>
        </div>
        <div className="doc-body">{body}</div>
      </div>
    </div>);

}

function GettingStarted() {
  return (
    <>
      <h1>Installation</h1>
      <p className="muted">Quick start for HopAIO on Windows. For the canonical version see the <a className="ext" href="https://bohmaan.github.io/HopAIO-docs/guide/installation" target="_blank">public docs</a>.</p>
      <h2>Download</h2>
      <p>Grab the latest <code>hopaio.exe</code> from the GitHub releases page.</p>
      <h2>Folder</h2>
      <p>Put it in any user-writable folder. Avoid <code>C:\Program Files\</code>.</p>
      <pre><code>{`C:\\Users\\<you>\\Desktop\\HopAIO\\
    hopaio.exe
    tasks.csv
    proxies.txt`}</code></pre>
      <h2>Defender exclusion</h2>
      <p>PyInstaller binaries get false-flagged. Add an exclusion (admin PowerShell):</p>
      <pre><code>Add-MpPreference -ExclusionPath "C:\Users\&lt;you&gt;\Desktop\HopAIO"</code></pre>
      <h2>First launch</h2>
      <p>Double-click the exe. It creates <code>sessions/</code>, <code>tasks/</code>, <code>proxy/</code> subfolders and checks for updates.</p>
      <div className="note">Once it finishes updating, prepare your CSV, drop a <code>proxies.txt</code>, and run again.</div>
    </>);

}
function CsvDoc() {
  return (
    <>
      <h1>Tasks CSV</h1>
      <p className="muted">The bot reads tasks from a CSV file. One row per task.</p>
      <h2>Required columns</h2>
      <table>
        <thead><tr><th>Column</th><th>Description</th></tr></thead>
        <tbody>
          <tr><td><code>site</code></td><td>Site code (see Supported Sites)</td></tr>
          <tr><td><code>sku</code></td><td>SKU, link, or keywords</td></tr>
          <tr><td><code>size</code></td><td>Comma-separated sizes or <code>random</code></td></tr>
          <tr><td><code>profile</code></td><td>Profile name from your accounts</td></tr>
          <tr><td><code>proxy</code></td><td>Proxy group or <code>none</code> for direct</td></tr>
        </tbody>
      </table>
      <div className="note">Tip: empty cells fall back to defaults defined in <code>config.toml</code>.</div>
    </>);

}
function ProxiesDoc() {
  return (
    <>
      <h1>Proxies</h1>
      <p>Drop a <code>proxies.txt</code> file with one proxy per line in the format <code>host:port:user:pass</code>.</p>
      <pre><code>{`# proxies.txt
1.2.3.4:8000:user:pass
5.6.7.8:8000:user:pass
# group: residential-uk
9.8.7.6:8000:user:pass`}</code></pre>
      <p>Reference proxy groups from your CSV under the <code>proxy</code> column. Use <code>none</code> for direct (proxyless) connections — recommended for most monitor tasks.</p>
    </>);

}
function SitesDoc({ onPick }) {
  return (
    <>
      <h1>Supported Sites</h1>
      <p className="muted">{SITES.length} sites supported across CZ, DE, AT, HU, PL, ES, IT, NL, DK, UK and the wider EU. Click a row to open its guide.</p>
      <table>
        <thead><tr><th>Site</th><th>Code</th><th>Region</th><th>Buy</th><th>Register</th></tr></thead>
        <tbody>
          {SITES.map((s) => {
            const g = SITE_GUIDES[s.code] || {};
            return (
              <tr key={s.code} style={{ cursor: 'pointer' }} onClick={() => onPick && onPick('site:' + s.code)}>
                <td><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: s.color, marginRight: 8 }}></span>{s.name}</td>
                <td><code>{s.code}</code></td>
                <td className="muted">{s.region}</td>
                <td style={{ color: g.buy === false ? 'var(--muted)' : 'var(--green)' }}>{g.buy === false ? '—' : '✓'}</td>
                <td style={{ color: g.register ? 'var(--green)' : 'var(--muted)' }}>{g.register ? '✓' : '—'}</td>
              </tr>);

          })}
        </tbody>
      </table>
    </>);

}

// ---------- SITE GUIDES ----------
const SITE_GUIDES = {
  alza: {
    regions: ['alza.cz', 'alza.de', 'alza.at', 'alza.hu'],
    buy: true,
    inputs: ['Product URL', 'SKU', 'EAN', 'Keywords'],
    notes: [
      'Pick the region by using the matching site code in your CSV: `alza.cz`, `alza.de`, `alza.at`, `alza.hu`. The bot picks the corresponding storefront + locale automatically.',
      'Guest checkout is supported — no account required. The customer is created on the first order and reused across tasks with the same email.',
      'Direct connection works fine for monitoring; residential / ISP proxies recommended on heavy releases (e.g., LEGO, console, GPU drops).',
      'EAN / SKU / keyword search all work: paste an EAN like `EAN: 4006381333115`, a numeric SKU, or the product title — the bot resolves it before ATC.',
      'Stock visibility is fast — the same `/api/product/...` endpoint the storefront uses is polled by the monitor for sub-second restock detection.',
    ],
    modes: [
      ['buy', 'Add → checkout → submit. Stops on success URL match.'],
      ['monitor', 'Poll for the product to flip to in-stock, then trigger linked buy tasks.'],
    ],
    shipping: [
      'Defaults to home delivery (Česká pošta / DPD / Zásilkovna depending on region).',
      'Set `pickup_id` in CSV to ship to an Alza box / Z-box for fastest finalize without courier.',
    ],
    payment: [
      'Default = Card on the Alza payment page (3DS handled by the bot redirect — you complete on phone).',
      '`cod` in `card_number` column → cash on delivery (CZ/SK only).',
      '`alza_credit` keyword → uses pre-saved Alza credit balance.',
    ],
    csv_columns: [
      ['site', 'yes', '`alza.cz` / `alza.de` / `alza.at` / `alza.hu`'],
      ['url', 'yes', 'Product URL, EAN string, SKU, or search keywords'],
      ['qty', 'no', 'Defaults to 1; capped at Alza per-customer limit'],
      ['profile', 'yes', 'Billing/shipping profile name'],
      ['proxy_pool', 'no', '`none` works for most regions'],
    ],
    flow: [
      'Resolve product → `/api/product/<id>` returns canonical SKU + stock',
      'POST `/Action/Basket/Add` → cart cookie set',
      'GET `/Checkout/CustomerEntry` → guest path',
      'POST address → POST shipping → POST payment → submit',
      'Success: `/checkout/recapitulation?orderId=<N>`',
    ],
    troubleshooting: [
      ['`HTTP 429` during ATC', 'Switch to residential / ISP proxy pool. Alza rate-limits aggressive cart pokes from datacenter IPs.'],
      ['`stock=0` though website shows green', 'Profile region mismatch — `alza.cz` site code with a DE warehouse SKU returns 0. Use the matching site code.'],
      ['Order placed but auto-cancelled in 2 min', 'Phone number invalid for the region. Use a local-format number (e.g., `+420 …` for CZ).'],
    ],
    sample: `alza.cz, https://www.alza.cz/EAN/dp/SOME-SKU, 1x, main_cz, none\nalza.de, EAN: 4006381333115, 2x, main_de, res-de-pool\nalza.hu, https://www.alza.hu/..., 1x, main_hu, none`
  },
  mediaexpert: {
    regions: ['PL'],
    buy: true,
    inputs: ['Product URL', 'SKU', 'EAN', 'Payment keyword'],
    notes: [
      'Codes: `mediaexpert` or `me`.',
      'The site is protected behind a custom JS challenge — the bot solves it in-process via curl_cffi + impersonation; no headless browser is involved during regular buy.',
      'In-store pickup auto-resolves the nearest store by `postal_code`. Override with `discount` column (POS id) or `config.json.mediaexpert_default_pos_id`.',
      'Cookie pre-warm gives a noticeable success bump on peak drops — leave a monitor task warming the same profile for ~10 min before the release.',
      'Cards: Polish-issued (Tpay) preferred. International cards often work but Tpay 3DS challenges international BINs harder.',
      "Per-account limit: 1 unit on hyped console drops, 2 on most other SKUs. Bot doesn't bypass — set `qty` realistically."
    ],
    modes: [
      ['buy', 'Default — parallel checkout, fastest path. Use this for restocks.'],
      ['backend', 'Sequential fallback that re-uses one fully-warmed session. Slower per-task but more reliable on aggressive WAF days.'],
      ['preload', 'Drop-snipe with a dummy SKU pre-cached; swap in the real SKU at release time. Hardest to detect.'],
    ],
    shipping: [
      'Default = home delivery (DPD / Inpost).',
      '`instore` keyword in `card_number` → store pickup with cash at register.',
      '`instorecod` keyword → store pickup with card at register (no 3DS, fastest finalize).',
      'Store auto-pick: bot calls `/api/stores?postal_code=…` and selects the closest one with stock. Override via `discount` column.',
    ],
    payment: [
      'Card (Tpay): real PAN in `card_number` column — bot handles 3DS via redirect URL in webhook.',
      '`gpay` → Google Pay token flow (requires saved card in the profile).',
      '`cod` → Gotówka przy odbiorze (cash on delivery).',
      '`instore` → pay cash at the register on pickup.',
      '`instorecod` → pay by card at the register on pickup.',
      'Full `payment_id` reference: 41 COD · 45 BLIK · 61 Card · 529 Apple Pay · 538 Google Pay (see public docs).',
    ],
    csv_columns: [
      ['site', 'yes', '`mediaexpert` or `me`'],
      ['url', 'yes', 'Product URL, SKU, or EAN'],
      ['qty', 'no', 'Realistic limit; default 1'],
      ['card_number', 'depends', 'Real PAN OR keyword `gpay` / `cod` / `instore` / `instorecod`'],
      ['discount', 'no', 'Override POS id for in-store pickup'],
      ['profile', 'yes', 'Polish billing profile required for some drops'],
    ],
    troubleshooting: [
      ['`HTTP 403` from `/api/cart/add`', 'WAF flagged the IP. Switch to a fresh PL residential proxy and re-warm the session.'],
      ['Order rejected after submit, "płatność nieudana"', 'Tpay declined the card. Try a Polish-issued BIN or switch to `cod`.'],
      ['Wrong store auto-picked', 'Set `discount` column to the exact POS id, or pin `mediaexpert_default_pos_id` in `config.json`.'],
      ['"Limit zamówień przekroczony"', 'Per-customer purchase limit hit. Rotate the profile email.'],
    ],
    sample: `mediaexpert, https://www.mediaexpert.pl/produkt/SKU, 1x, main_pl, none\nme, EAN: 5901234567890, 1x, main_pl, isp-pl-pool\nme, https://www.mediaexpert.pl/..., 1x, main_pl, none ; card_number=instorecod`
  },
  mediamarkt: {
    regions: ['NL'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'Product ID'],
    notes: [
      'Codes: `mediamarkt`, `mm`, or `mediamarkt.nl`. NL region. DE storefront lives under `mediamarkt.de` / `mmde` — separate dashboard entry.',
      'Apollo PWA persisted-query GraphQL. Single endpoint `/api/v1/graphql`. Queries GET, mutations POST. PQM hashes pinned to client build `2265558`; if MM ships a new build the bot fails with `PersistedQueryNotFound` and the hashes must be re-mined.',
      'Cloudflare WAF + Forter behavioural fingerprint. Bot solves CF via `uncaptcha.io` `waf` task (set `uncaptcha_key` in `config.json`).',
      'When uncaptcha returns `no_challenge` (uncaptcha sees the IP loading clean while curl_cffi still 403s), it means the proxy IP is on MM\'s blocklist for our session — bot prints `403 aborting task` and exits so operator can swap proxies.',
      'PayPal Express Checkout is default. Bot captures the hosted pay URL (`https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=EC-<token>`) and webhooks it; operator never touches a card form.',
      'Hot path target → pay URL: ≈ 1.5 s with v2.8 preload (PayPal). Breakdown: ATC ~400 ms + DeleteLineItem(dummy) ~300 ms + Finalize ~800 ms. The v2.8 path keeps the pre-warm dummy IN the basket until WIN so SetFulfillment\'s bound `fulfillmentOptionsId` chains directly into Finalize without a refresh RTT; previous v2 (~1.75 s) wiped the dummy off-critical in pre-warm but had to re-fetch the basket post-ATC to bind the target\'s new mmsGroup. Toggle via module flags `_MM_HOT_KEEP_DUMMY_THRU_WIN` / `_MM_HOT_KEEP_DUMMY_REFRESH` / `_MM_PRELOAD_V3` / `_MM_PRELOAD_V2` / `_MM_HOT_REACTIVATE_PAY` if A/B testing.',
      'Even faster: switch `payment_method=ideal` on the task row — server-side Finalize avoids the slow PayPal Express init (~600–1000 ms upstream) and returns an iDEAL bank-picker URL inline. Total hot wall drops to roughly ~800 ms. Bot already supports iDEAL end-to-end (`AddPayment(IDEAL)` in pre-warm, Finalize → `paymentAction.target` = iDEAL bank selector URL → webhook → user opens, picks bank, completes).',
    ],
    modes: [
      ['buy', 'Full single-shot. Guest if no email+password on task row; account login if creds given.'],
      ['preload', 'Drop-snipe with always-removed dummy filler. Pre-warm loads a dummy USB-C cable, sets address + PayPal off-critical; hot loop swaps target for dummy + Finalize. Webhook with PayPal URL on win.'],
      ['test', 'Pre-warm everything up through AddPayment but STOP before Finalize. Used to verify PQM hashes still parse after MM deploys.'],
      ['register', 'Create a MyMediaMarkt account via DOI. User must open the email confirmation link before login works.'],
      ['login', 'Smoke-test stored credentials. Prints accessToken latency on success.'],
    ],
    shipping: [
      'Standard home delivery (DPD / PostNL) — default, free.',
      'Express delivery / WishDay scheduled / Wolt / Uber — supported by the schema but bot picks cheapest (= standard) automatically.',
      'Store pickup not yet wired; ask if needed.',
    ],
    payment: [
      '`paypal` (default) → PayPal Express Checkout, webhook returns hosted pay URL.',
      '`ideal` → iDEAL bank redirect (NL).',
      '`klarna` / `achteraf` → Klarna pay later.',
      '`card` / `creditcard` / `cc` → Creditcard via TPay/Adyen.',
      '`apple_pay` / `applepay` → Apple Pay (requires saved card in profile).',
    ],
    csv_columns: [
      ['site', 'yes', '`mediamarkt`, `mm`, or `mediamarkt.nl`'],
      ['url', 'yes', 'Product URL (`/nl/product/_<slug>-<id>.html`) or bare numeric productId'],
      ['qty', 'no', 'Default 1; MM enforces realistic limits server-side'],
      ['email', 'depends', 'Required for guest; if password also set, bot tries account login first'],
      ['payment_method', 'no', '`paypal` (default) / `ideal` / `klarna` / `card` / `apple_pay`'],
      ['street + building_number + city + postal_code', 'yes', 'NL address; postal code normalized to `NNNNXX` (no space). Fallback pool of central NL postcodes if validator rejects user value.'],
      ['phone', 'no', 'Normalized to `+31<9-digits>` automatically'],
    ],
    troubleshooting: [
      ['`403 aborting task — (IP banned, swap proxy and re-run)`', 'Uncaptcha sees the IP loading clean but our session is still 403\'d. Swap to a fresh proxy and re-run.'],
      ['`OOS placing order (retrying)`', 'Target hit `BASKET_DELIVERY_PROMISE_REQUIRED` (109016) at Finalize — ATC accepted but the item is OOS at order-creation. Bot keeps retrying; operator Ctrl-C if drop is over.'],
      ['`PersistedQueryNotFound`', 'MM shipped a new client build; the PQM hashes in the module are stale. Operator must re-mine the hashes from the new JS bundle (see `_PQ_REVISION` sentinel).'],
      ['`ATC failed — out_of_stock`', 'Target genuinely OOS at ATC step — bot retries forever on `task.delay` cadence.'],
      ['`ATC capped (retrying) — BASKET_ITEM_MAX_QUANTITY`', 'MM\'s drop-mode anti-scalper rule. Fires for several minutes after a successful win on the same SKU (per-customer cooldown by IP / session / payment id / email). Bot treats it as retryable — keeps polling until the cap lifts. If the run drags past your drop window, operator C-c\'s. To bypass: rotate proxy, swap account, or wait out the cooldown.'],
      ['Captcha credit drained on a single drop', 'Each `403 aborting task` burns one uncaptcha solve. If a proxy is permanently banned the bot may run several before aborting — pre-rotate proxies between attempts.'],
    ],
    sample: `mediamarkt, https://www.mediamarkt.nl/nl/product/_apple-60w-usb-c-oplaadkabel-1m-1870991.html, 1x, main_nl, none ; mode=buy\nmm, 1880515, 1x, main_nl, res-nl-pool ; mode=preload\nmm, https://www.mediamarkt.nl/nl/product/..., 1x, main_nl, none ; payment_method=ideal`,
  },
  mediamarkt_de: {
    regions: ['DE'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'Product ID'],
    notes: [
      'Codes: `mediamarkt.de` or `mmde`. Same module + same PQM hashes as NL (build `2265558`, 320 ops shared) — DE just differs on headers (`x-mms-country: DE`, `x-mms-language: de`), one extra extension flag (`isUsingXccCustomerComponent: true`), address shape (5-digit PLZ), phone prefix (+49) and Finalize `displayText` ("Lieferung am …" / `DD.MM.YYYY`).',
      'Cloudflare WAF + Forter (identical to NL). Same `uncaptcha.io` `waf` task type, same `uncaptcha_key` in `config.json`.',
      '`403 aborting task` on uncaptcha `no_challenge` — swap proxy and re-run.',
      'PayPal Express Checkout default (`paypal`). DE also exposes `klarna` / `card` / `apple_pay`; iDEAL is NL-only. Hosted PayPal pay URL captured + webhooked the same as NL.',
      'Dummy filler pool: starts with a single ISY Lightning cable (`2163797`, ~€14) confirmed in stock 2026-05-24. Extend the pool the first time it goes OOS mid-drop — search `_REGIONS["DE"].dummy_product_ids` in `mediamarkt.py`.',
      'Hot path matches NL: v3 preload lands at ≈ 1.6 s (PayPal) / ≈ 0.8 s (Klarna direct redirect, similar to NL\'s iDEAL — DE has no iDEAL, only Klarna / card / Apple Pay as fast alternatives to PayPal). Same module flags (`_MM_PRELOAD_V3` etc).',
      'Postcode pool: 8 major-city centrals (Berlin/Munich/Hamburg/Köln/Frankfurt/Stuttgart/Düsseldorf/Hannover). DE Loqate expects 5 digits, no spaces — bot strips/validates automatically.',
    ],
    modes: [
      ['buy', 'Full single-shot. Guest if no email+password on task row; account login (DOI) if creds given.'],
      ['preload', 'Drop-snipe with always-removed dummy filler. Same hot-loop shape as NL — target ATC → DeleteLineItem(dummy) → GetBasket refresh → Finalize. ~1.9 s wall clock to PayPal URL.'],
      ['test', 'Pre-warm everything up through AddPayment but STOP before Finalize. Used to verify PQM hashes still parse + DE-specific Finalize displayText format passes server validation.'],
      ['register', 'Create a MyMediaMarkt DE account via DOI. User must open the email confirmation link before login works.'],
      ['login', 'Smoke-test stored credentials.'],
    ],
    shipping: [
      'Standard home delivery (DHL/DPD/UPS) — default, free above €69 order value.',
      'Express delivery / Wolt / Uber Eats supported by the schema; bot picks cheapest (= standard) automatically.',
      'Store pickup not yet wired; ask if needed.',
    ],
    payment: [
      '`paypal` (default) → PayPal Express Checkout, webhook returns hosted pay URL.',
      '`klarna` / `achteraf` → Klarna pay later.',
      '`card` / `creditcard` / `cc` → Creditcard.',
      '`apple_pay` / `applepay` → Apple Pay.',
      'iDEAL is NL-only and not available on DE.',
    ],
    csv_columns: [
      ['site', 'yes', '`mediamarkt.de` or `mmde`'],
      ['url', 'yes', 'Product URL (`/de/product/_<slug>-<id>.html`) or bare numeric productId'],
      ['qty', 'no', 'Default 1; MM enforces realistic limits server-side'],
      ['email', 'depends', 'Required for guest; if password also set, bot tries account login first'],
      ['payment_method', 'no', '`paypal` (default) / `klarna` / `card` / `apple_pay`'],
      ['street + building_number + city + postal_code', 'yes', 'DE address; postal code = 5 digits (no letters). Fallback pool of major-city PLZs if validator rejects user value.'],
      ['phone', 'no', 'Normalized to `+49<10-digits>` automatically'],
    ],
    troubleshooting: [
      ['`403 aborting task — (IP banned, swap proxy and re-run)`', 'Uncaptcha sees the IP loading clean but our session is still 403\'d. Swap to a fresh proxy and re-run.'],
      ['`OOS placing order (retrying)`', 'Target hit `BASKET_DELIVERY_PROMISE_REQUIRED` (109016) at Finalize — ATC accepted but the item is OOS at order-creation. Bot keeps retrying; operator Ctrl-C if drop is over.'],
      ['`PersistedQueryNotFound`', 'MM shipped a new client build; the PQM hashes are stale. Re-mine from the new JS bundle (same chunk pattern on both regions, since DE + NL ship the same PWA build).'],
      ['`ATC failed — out_of_stock`', 'Target genuinely OOS at ATC — bot retries forever on `task.delay` cadence.'],
      ['`Address failed (retrying) — invalid postcode`', 'DE postcode validator rejected — bot falls through to PLZ pool (Berlin/Munich/etc). If user value is correct and still fails, the proxy IP region may be tripping a fraud rule; rotate proxy.'],
    ],
    sample: `mediamarkt.de, https://www.mediamarkt.de/de/product/_isy-iuc-2001-2163797.html, 1x, main_de, none ; mode=test\nmmde, 2163797, 1x, main_de, res-de-pool ; mode=preload\nmmde, https://www.mediamarkt.de/de/product/..., 1x, main_de, none ; payment_method=klarna`,
  },
  empik: {
    regions: ['PL'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'EAN'],
    notes: [
      'Account required for early-access drops — use the `register` module first. Guest checkout exists but is rate-limited harder.',
      'Empik throttles aggressively on shared IPs; ISP proxies recommended for buy tasks. Datacenter IPs trigger CAPTCHA on most attempts.',
      'EAN input is supported as `9788328070110` (no prefix). The bot resolves it via `/produkt/<ean>` redirect.',
      '"Empik Premium" subscription auto-applies free shipping if the profile is linked.',
    ],
    modes: [
      ['buy', 'Logged-in checkout; fastest path.'],
      ['register', 'Create an Empik account. Manual email confirmation required before first buy.'],
    ],
    shipping: [
      'Default: home delivery via Inpost / DPD.',
      'Empik kiosk (in-store pickup) — auto-resolves to closest kiosk by `postal_code`.',
      'Paczkomat Inpost — set `pickup_id` to a specific locker.',
    ],
    payment: [
      'Card (Tpay or PayU depending on cart total).',
      'BLIK code prompt — bot pauses and webhooks the BLIK confirmation URL.',
      'COD on Inpost only (small fee added).',
    ],
    csv_columns: [
      ['site', 'yes', '`empik`'],
      ['url', 'yes', 'Product URL or 13-digit EAN'],
      ['qty', 'no', 'Default 1; per-customer limit on hyped books'],
      ['profile', 'yes', 'Polish billing required'],
      ['proxy_pool', 'recommended', 'ISP-PL or residential-PL pool'],
    ],
    troubleshooting: [
      ['CAPTCHA challenge appears in webhook', 'Switch to a clean ISP-PL proxy and re-warm the session by browsing 2-3 product pages before ATC.'],
      ['"Konto nieaktywne"', 'Email confirmation link not clicked after register. Confirm manually and retry.'],
      ['BLIK timeout', 'Increase webhook reply window or switch to card.'],
    ],
    sample: `empik, 9788328070110, 2x, empik_pl, isp-pl-pool\nempik, https://www.empik.com/..., 1x, empik_pl, isp-pl-pool`
  },
  elbenwald: {
    regions: ['DE/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'Keywords'],
    notes: ['Codes: `elbenwald` or `ew`.', 'Newsletter signup unlocks pre-orders for some Pokémon SKUs.'],
    sample: `ew, https://www.elbenwald.de/product/..., 1x, main_de, none`
  },
  basketballemotion: {
    regions: ['ES/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `basketballemotion` or `be`.', 'Spanish billing address often required — set on the profile.'],
    sample: `be, https://www.basketballemotion.com/..., EU 43, sneaker_es, res-es-pool`
  },
  futbolemotion: {
    regions: ['ES/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `futbolemotion` or `fe`.', 'Same checkout backend as BasketballEmotion — profiles transfer.'],
    sample: `fe, https://www.futbolemotion.com/..., EU 42, main_es, none`
  },
  mycomics: {
    regions: ['IT'],
    buy: true,
    inputs: ['Product URL', 'EAN'],
    notes: ['Codes: `mycomics` or `mc`.', 'Heavy on TCG drops — proxyless monitor + 1-2 buy tasks per profile is plenty.'],
    sample: `mc, https://www.mycomics.it/..., 3x, tcg_it, none`
  },
  fantasiastore: {
    regions: ['IT'],
    buy: true,
    register: true,
    inputs: ['Product URL'],
    notes: ['Codes: `fantasiastore` or `fa`.', 'Manual captcha fallback recommended on Saturdays.'],
    sample: `fa, https://www.fantasiastore.it/..., 1x, tcg_it, res-it-pool`
  },
  gamesisland: {
    regions: ['DE/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `gamesisland` or `gi`.', 'Strict per-account purchase limit on TCG — rotate profiles.'],
    sample: `gi, https://www.gamesisland.de/..., 2x, tcg_de, none`
  },
  proshop: {
    regions: ['proshop.de', 'proshop.at', 'proshop.nl', 'proshop.dk'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: [
      'Region picked via TLD: `proshop.de` / `.at` / `.nl` / `.dk`. Code aliases: `proshop` or `ps` (defaults to `.de`).',
      'Pre-built accounts dramatically speed up checkout — first run uses `register` mode, then keep sessions warm in `sessions/`. Session file is named `proshop_<email>.json`.',
      'NL/DK regions sometimes require IDEAL / Mobilepay — configure those on the profile, the bot does not enroll new methods.',
      'Cart validity is short (~10 min). Do not pre-warm carts hours before — refresh just before the drop.',
      "Bot uses Proshop's fast-checkout path which skips the multi-step UI and POSTs directly to `/Checkout/Order/Place` after a one-shot address validation.",
    ],
    modes: [
      ['buy', 'Standard checkout with logged-in account.'],
      ['register', 'Create the account first. Required once per email; subsequent buys reuse the session.'],
    ],
    shipping: [
      'DE/AT: DPD home delivery (default), or DHL packstation if `pickup_id` is set.',
      'NL: PostNL home, or pickup point.',
      'DK: GLS home or Pakkeshop pickup.',
    ],
    payment: [
      'Card (3DS via redirect URL in webhook).',
      'Klarna pay-after on DE/AT/NL — profile must be pre-approved.',
      'iDEAL on NL, Mobilepay on DK — both require the profile to have the method linked.',
    ],
    csv_columns: [
      ['site', 'yes', '`proshop.de` / `proshop.at` / `proshop.nl` / `proshop.dk` (or `proshop` for DE)'],
      ['url', 'yes', 'Product URL or SKU'],
      ['profile', 'yes', 'Must match the region (DE billing for `.de`, etc.)'],
      ['proxy_pool', 'no', 'Direct usually fine; ISP recommended on hyped drops'],
    ],
    troubleshooting: [
      ['"Login failed" on a freshly-registered account', 'Activation email not clicked. Proshop requires manual email confirmation before the account can place orders.'],
      ['Cart silently empties between ATC and checkout', 'Cart timed out (>10 min). Re-run the task.'],
      ['Payment page redirect loop', '3DS challenge timed out. Increase webhook reply window and complete the redirect within 5 min.'],
    ],
    sample: `proshop, https://www.proshop.de/..., 1x, main_de, none\nps, https://www.proshop.nl/..., 2x, main_nl, none\nproshop.at, https://www.proshop.at/..., 1x, main_at, none`
  },
  solebox: {
    regions: ['DE', 'EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `solebox` or `sb`. Region selectable on launches that gate by country.', 'Raffle launches: set task to monitor + auto-enter; buy tasks fire on win.', 'Cookie consent must be accepted once per session — handled automatically.'],
    sample: `sb, https://www.solebox.com/..., EU 43, sneaker_de, res-de-pool`
  },
  footshop: {
    regions: ['CZ/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `footshop` or `fs`.', 'CZ checkout is fastest; use Czech billing for the smoothest run.'],
    sample: `fs, https://www.footshop.com/..., EU 42, main_cz, none`
  },
  skatedeluxe: {
    regions: ['DE/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `skatedeluxe` or `sd`.', 'Newsletter discount (10%) auto-applies if profile email is subscribed.'],
    sample: `sd, https://www.skatedeluxe.com/..., EU 43, main_de, none`
  },
  sportvision: {
    regions: ['CZ'],
    buy: true,
    inputs: ['Product URL'],
    notes: ['Codes: `sportvision` or `sv`.', 'Limited stock visibility — pair with a proxyless monitor.'],
    sample: `sv, https://www.sportvision.cz/..., EU 42, main_cz, none`
  },
  sportsshoes: {
    regions: ['UK/EU'],
    buy: true,
    register: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Codes: `sportsshoes` or `ss`.', 'UK billing recommended on hyped drops to avoid post-checkout cancels.'],
    sample: `ss, https://www.sportsshoes.com/..., EU 43, sneaker_uk, res-uk-pool`
  },
  frasers: {
    regions: ['UK/EU'],
    buy: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['One bot, many fronts: `game`, `sportsdirect`, `flannels`, `studio`, `houseoffraser`, `usc`, `everlast`. Pick the right code per task.', 'Frasers Plus account speeds up payment — link on the profile.', 'Queue-it is common; the bot handles passes automatically.'],
    sample: `sportsdirect, https://www.sportsdirect.com/..., UK 9, sneaker_uk, res-uk-pool\nflannels, https://www.flannels.com/..., UK 10, main_uk, res-uk-pool`
  },
  skstore: {
    regions: ['PL/EU'],
    buy: true,
    inputs: ['Product URL', 'SKU'],
    notes: ['Code: `skstore` (a.k.a. WSS).', 'No registration required for buy.'],
    sample: `skstore, https://www.skstore.pl/..., EU 42, main_pl, none`
  },
  zalando: {
    regions: ['zalando.de', 'zalando.cz', 'zalando.pl', 'zalando.it', 'zalando.es', 'zalando.fr', 'zalando.nl', 'zalando.at'],
    buy: true,
    inputs: ['Product URL', 'SKU', 'Size'],
    notes: [
      "Codes: `zalando` or `zal`. Region inferred from the product URL's TLD if not pinned in `site`.",
      'Account required for fast checkout — keep sessions in `sessions/` warm. Fresh login adds ~12s to the run.',
      'Pay-after-delivery (Klarna) profiles often have higher success on hype drops; they bypass 3DS entirely.',
      'Size mapping: pass EU sizing (`EU 43`, `EU 42 2/3`); the bot picks the matching variant SKU. Use the exact string from the product page.',
      'Cart cookie + session cookie are both required — losing either silently empties the cart at submit.',
    ],
    modes: [
      ['buy', 'Logged-in checkout. Always preferred.'],
    ],
    shipping: [
      'Default: home delivery (DHL / DPD per region).',
      'Some regions support packstation; auto-detected if profile has one saved.',
    ],
    payment: [
      'Klarna pay-after-delivery — fastest, no 3DS, requires linked profile.',
      'Card with 3DS — bot webhooks the redirect URL.',
      'PayPal — profile must have the account linked.',
    ],
    csv_columns: [
      ['site', 'yes', '`zalando` / `zal` (or pinned TLD like `zalando.de`)'],
      ['url', 'yes', 'Product URL (variant page)'],
      ['size', 'yes for clothes/shoes', 'EU sizing string, e.g., `EU 43`'],
      ['profile', 'yes', 'Region-matched billing profile'],
      ['proxy_pool', 'no', 'Direct works; ISP if banned'],
    ],
    troubleshooting: [
      ['Cart empties at submit', 'Session cookie expired mid-flow. Re-run; the bot refreshes automatically on retry.'],
      ['"Diese Größe ist nicht verfügbar"', 'Size sold out between PDP fetch and ATC. Add a monitor task to wait for restock.'],
      ['Klarna declined', 'Profile failed Klarna scoring. Switch to card temporarily.'],
    ],
    sample: `zal, https://www.zalando.de/..., EU 43, main_de, none\nzalando.cz, https://www.zalando.cz/..., EU 42, main_cz, none`
  },
  secretlair: {
    regions: ['secretlair.wizards.com'],
    register: true,
    inputs: ['Product URL'],
    notes: [
      'Codes: `secretlair` or `sl`. `secretlair.wizards.com` on the Scalefast "Pepita" platform.',
      'No guest checkout — an account is required. Use `register` to create one, or `login` if it already exists.',
      'Pure HTTP end-to-end: token mint, account, cart, Queue-it and the PayPal hold are all API calls. The bot never opens a browser.',
      'Drops are gated by Queue-it — the bot fetches the signed queue token transparently, no pass link to click and no separate `queueit` task.',
      'Intermittent CloudFront 403s on flagged proxy IPs are absorbed automatically (TLS-fingerprint reroll, then direct fallback).',
    ],
    modes: [
      ['register', 'Create the account, mint tokens, ride the queue, build the cart, send the PayPal approval link to the webhook.'],
      ['login', 'Same as register but with an existing account (no signup step). Address columns optional if the account already has one.'],
    ],
    payment: [
      'PayPal is a CART HOLD, not a card charge. The bot does not handle a card or 3DS.',
      'It produces the PayPal approval link and sends it to your webhook — you open it and approve to finish the order.',
      'Exactly one private webhook (full payload, with the PayPal link) + the public success webhook. Webhook label = `PayPal`.',
    ],
    csv_columns: [
      ['site', 'yes', '`secretlair` or `sl`'],
      ['url', 'yes', 'Product URL on secretlair.wizards.com'],
      ['mode', 'yes', '`register` or `login` (no `buy`/guest)'],
      ['email + password', 'yes', 'Account credentials (created on register, reused on login)'],
      ['first_name / last_name', 'register', 'Billing identity for new accounts'],
      ['postal_code / city / street / country_code', 'register', 'Address attached to the account / order'],
    ],
    flow: [
      'Mint app token + user token (OAuth, plaintext credential — Scalefast `secret:oauth`)',
      'register → create account → login; login → /user/me for userID',
      'Resolve product → Queue-it signed token via `/javascriptqueue/` JSONP',
      'Build cart (app token, region = buyer ISO country)',
      'Braintree `paypal_hermes/setup_billing_agreement` → approval URL',
      'Webhook the PayPal approval link (no browser, no card)',
    ],
    sample: `sl, https://www.secretlair.wizards.com/product/..., , hu_main, none ; mode=register\nsl, https://www.secretlair.wizards.com/product/..., , hu_main, none ; mode=login`,
    troubleshooting: [
      ['`Auth failed: no app token`', 'Proxy IP flagged by CloudFront (403). Bot rerolls fingerprint then goes direct — if it persists, swap to a cleaner proxy.'],
      ['`Queue-it invalid cookie`', 'Stale queue token. Bot re-fetches the signed cookieValue automatically on retry.'],
      ['No PayPal link in webhook', 'Cart total exceeded `max_price` before the hold, or the account has no address (register mode needs the address columns).'],
      ['Two private webhooks', 'Fixed in v2.7.1 — update the bot. One private + one public is correct.'],
    ],
  },
  lowkey: {
    regions: ['reflexshop.hu', 'cardverse.hu', 'dragon-world.store', 'cr7museumstore.com'],
    buy: true,
    register: true,
    inputs: ['Product URL'],
    notes: ['Low-key shops bundled under one site entry — two HU TCG / board-game stores plus two WooCommerce shops (CZ + PT). See the individual guides below for the per-shop checkout flow.'],
    subguides: [
      {
        name: 'Reflexshop (UNAS)',
        notes: [
          'Codes: `reflexshop` or `rs`. HU board-game / TCG / Funko shop on UNAS.',
          'Invisible reCAPTCHA v2 gates `/shop_reg.php` and guest checkout. Register once via real-Chrome nodriver, then `buy` logs in captcha-free.',
          'Register tasks capped to 3 in parallel (browser RAM + IP-burst safety). Buy tasks fully parallel.',
          'Generated accounts logged to `genned_accounts.csv` via `log_gen`; auto-logged-in after creation.',
        ],
        modes: [
          ['buy', 'Cart → login → ship/pay → submit. Captcha-free with a pre-registered account.'],
          ['register', 'Real-Chrome nodriver on `/shop_reg.php?no_reg=0`. Trust-the-submit — always logs the gen.'],
        ],
        shipping: [
          'GLS csomagautomata only. Pool of 3 lockers in Tolna 7130 (Deák Ferenc u 84 / Alkotmány u 70/B / Alkotmány u 22), random pick per task.',
          'HU-only — reflexshop neships mimo Maďarsko.',
        ],
        payment: [
          'Default = `Bankkártyás fizetés a csomagautomatánál` (pay-by-card at locker). Webhook label = `COD`.',
          'Barion / SimplePay gateway path also detected — bot captures the redirect URL.',
        ],
        csv_columns: [
          ['site', 'yes', '`reflexshop` or `rs`'],
          ['url', 'yes', 'Product detail URL on reflexshop.hu'],
          ['email + password', 'yes', 'Password used for both register and buy-mode auto-login'],
          ['first_name / last_name', 'yes', 'Mandatory billing identity'],
          ['phone', 'no', 'Empty → bot rolls random HU mobile (20/30/31/70 + 7 digits)'],
          ['postal_code / city / street', 'yes', 'HU address; bot forces `country=hu` regardless'],
        ],
        flow: [
          'register: launch nodriver Chrome → `/shop_reg.php?no_reg=0` → dismiss cookie overlay → fill 8 fields → force-check `terms` via JS → click `#button_reg` → wait 5s → close → log gen',
          'buy: ATC `/shop_ajax/ajax_cart.php` → login `/shop_logincheck.php` → bind GLS locker on `/shop_order_mods.php` → POST `/shop_order_control.php` → POST `/shop_order_send.php`',
        ],
        sample: `rs, https://reflexshop.hu/termek/..., 1x, hu_main, res-hu-pool ; mode=register\nrs, https://reflexshop.hu/termek/..., 1x, hu_main, none ; mode=buy`,
        troubleshooting: [
          ['`overlay=recaptcha_error`', 'Captcha siteverify fail. Real Chrome should pass; if not, IP flagged — switch to a clean residential.'],
          ['`overlay=cust_error`', 'Form validator rejected. Usually email already registered (`f_cust_username=1`).'],
          ['`Chrome not installed`', 'Install Chrome on the bot host (chrome.com). Edge fallback also works.'],
          ['Stock-cap on ATC', 'Bot auto-retries with the server-reported max qty. Logs `ATC: qty capped`.'],
        ],
      },
      {
        name: 'Cardverse (WooCommerce)',
        notes: [
          'Codes: `cardverse` or `cv`. HU TCG (Pokémon / MTG) + Funko shop on WordPress + WooCommerce 10.7.',
          'Pure Store API checkout — no captcha, no admin key, no login required. 5 requests, ~3-5s end-to-end.',
          'Product discovery resolves PDP slug via `/products?slug=<slug>` to numeric ID.',
          'Guest checkout fully supported; no register module needed.',
        ],
        modes: [
          ['buy', 'Add → address → shipping → checkout. Card or COD bundled in one POST.'],
        ],
        shipping: [
          '`flat_rate:2` — Házhoz szállítás (~2000 Ft, default, supports COD)',
          '`local_pickup:3` — Személyes átvétel (free store pickup)',
          '`vp_pont:4` — Csomagpont (free parcel point — does NOT support COD)',
          'Override via CSV `shipping_method`. Default auto-picks `flat_rate:2`.',
        ],
        payment: [
          'Default = `revolut_cc`. Bot creates pending order + webhooks pay URL `https://www.cardverse.hu/penztar/order-pay/<id>/?pay_for_order=true&key=<key>`. Webhook label = `Payment link`.',
          '`cod` — Utánvét. Restricted: preorder products + non-home shipping reject COD with HTTP 400.',
          '`revolut_payment_request` — Apple/Google Pay via Revolut.',
        ],
        csv_columns: [
          ['site', 'yes', '`cardverse` or `cv`'],
          ['url', 'yes', 'Product URL — must contain `/termek/<slug>/`'],
          ['email', 'yes', 'Receipt email; no account needed'],
          ['first_name / last_name', 'yes', 'Billing identity'],
          ['postal_code / city / street', 'yes', 'Any HU address'],
          ['payment_method', 'no', '`revolut_cc` (default) / `cod` / `revolut_payment_request`'],
          ['shipping_method', 'no', '`flat_rate:2` (default) / `local_pickup:3` / `vp_pont:4`'],
        ],
        flow: [
          'GET `/wp-json/wc/store/v1/cart` → seed Nonce + Cart-Token',
          'GET `/wp-json/wc/store/v1/products?slug=<slug>` → resolve product id',
          'POST `/wp-json/wc/store/v1/cart/add-item`',
          'POST `/wp-json/wc/store/v1/cart/update-customer` (billing + shipping)',
          'POST `/wp-json/wc/store/v1/cart/select-shipping-rate`',
          'POST `/wp-json/wc/store/v1/checkout` → order_id + pay URL',
        ],
        sample: `cv, https://www.cardverse.hu/termek/..., 1x, hu_main, none\ncv, https://www.cardverse.hu/termek/..., 2x, hu_main, none ; payment_method=cod`,
        troubleshooting: [
          ['`woocommerce_rest_product_out_of_stock`', 'Self-explanatory; pair with a monitor task.'],
          ['`Utánvétes fizetés nem érhető el`', 'COD rejected — product is preorder (category `Előrendelés`) or shipping is not home. Switch to `revolut_cc` or use `flat_rate:2`.'],
          ['Empty pay-for-order page', 'Cardverse uses `/penztar/order-pay/` (renamed slug). Bot generates the right URL.'],
          ['HTTP 400 on checkout but order_id present', 'Gateway leg failed but WC saved order as pending. Bot still succeeds and webhooks pay URL.'],
        ],
      },
      {
        name: 'Dragon World (WooCommerce)',
        notes: [
          'Codes: `dragonworld`, `dw` or `dragon-world`. `dragon-world.store` — CZ WooCommerce shop (TCG / board games).',
          'Pure Store API checkout — no captcha, no login. Guest only. Product URL is the `/produkt/<slug>/` PDP. Currency CZK.',
          'Product discovery resolves the slug via `/products?slug=<slug>` to a numeric ID.',
        ],
        modes: [
          ['buy', 'Add → address → PPL home → checkout. GP Webpay pay link forwarded to the webhook.'],
        ],
        shipping: [
          '`toret_ppl_home` — PPL Private doručení na adresu (home delivery, default)',
          '`toret_ppl_point` — Doručení do PPL Parcelshopu',
          '`dpd_pickup` — Doručení na výdejní místo DPD Pickup',
          '`local_pickup:2` — Vyzvednout na prodejně (Slaný, free)',
          'Override via CSV `shipping_method`. Default auto-picks `toret_ppl_home`.',
        ],
        payment: [
          'Default = `gpwebpay` (GP Webpay card). Bot creates the order, then chases the one 302 from the signed GP Webpay entry URL and webhooks the real hosted-card link `https://3dsecure.gpwebpay.com/pgw/card?pgwSessionId=...`. The bot never touches the card form / 3DS and never signs anything. Webhook label = `Card (GP webpay)`.',
          '`pnu` — bank-transfer style gateway (finalizes server-side).',
        ],
        csv_columns: [
          ['site', 'yes', '`dragonworld` or `dw`'],
          ['url', 'yes', 'Product URL — must contain `/produkt/<slug>/`'],
          ['email', 'yes', 'Receipt email; no account needed'],
          ['first_name / last_name', 'yes', 'Billing identity'],
          ['postal_code / city / street', 'yes', 'Any CZ address'],
          ['payment_method', 'no', '`gpwebpay` (default) / `pnu`'],
          ['shipping_method', 'no', '`toret_ppl_home` (default) / `toret_ppl_point` / `dpd_pickup` / `local_pickup:2`'],
        ],
        flow: [
          'GET `/wp-json/wc/store/v1/cart` → seed Nonce + Cart-Token',
          'GET `/wp-json/wc/store/v1/products?slug=<slug>` → resolve product id',
          'POST `/wp-json/wc/store/v1/cart/add-item`',
          'POST `/wp-json/wc/store/v1/cart/update-customer` (billing + shipping)',
          'POST `/wp-json/wc/store/v1/cart/select-shipping-rate` (toret_ppl_home)',
          'POST `/wp-json/wc/store/v1/checkout` → order_id + GP Webpay entry URL',
          'Follow the 302 → webhook `pgw/card?pgwSessionId=...`',
        ],
        sample: `dw, https://dragon-world.store/produkt/..., 1x, cz_main, none\ndw, https://dragon-world.store/produkt/..., 1x, cz_main, none ; payment_method=pnu`,
        troubleshooting: [
          ['`woocommerce_rest_product_out_of_stock`', 'Self-explanatory; pair with a monitor task — bot polls and retries ATC.'],
          ['Pay link points at `/pgw/order.do`', 'Resolver could not chase the 302 (network blip). The entry URL is still usable; re-run for the clean `/pgw/card` link.'],
          ['HTTP 400 on checkout but order_id present', 'Gateway leg pending — bot still succeeds and webhooks the pay URL.'],
        ],
      },
      {
        name: 'CR7 Museum Store (WooCommerce)',
        notes: [
          'Codes: `cr7`, `cr7museum`, `cr7museumstore` or `cr7museumstore.com`. `cr7museumstore.com` — PT WooCommerce shop (CR7 museum merch). Currency EUR.',
          'Pure Store API checkout — no captcha, no login. Guest only. Product URL is the `/produto/<slug>/` PDP.',
          'Cloudflare WAF-blocks the clean `/wp-json/` path (403 challenge); bot uses the `/?rest_route=/wc/store/v1...` form which passes transparently.',
          'Most products are variable (colour `pa_cor` — branco / preto). Bot picks the variation from the `sizes` CSV token (matches attribute value / term slug / term name), else the first available.',
        ],
        modes: [
          ['buy', 'Add → address → checkout. EasyPay hosted pay link forwarded to the webhook.'],
        ],
        shipping: [
          '`flexible_shipping_2_1` — CTT Expresso (single rate, auto-selected by WC).',
          'Override via CSV `shipping_method` only if a different rate appears; default leaves WC\'s selected rate.',
        ],
        payment: [
          'Default = `creditcard_checkout` (EasyPay hosted card page). Bot creates the order, then webhooks the EasyPay pay link `https://cr7museumstore.com/wc-easypay/?order_id=<id>&key=<k>&easypay_param=<b64>`. The bot never touches the card form / 3DS. Webhook label = `Card online`.',
          'Override via CSV `payment_method` (`mbway_checkout`, `multibanco_checkout`, `applepay_checkout`, `googlepay_checkout`, …).',
        ],
        csv_columns: [
          ['site', 'yes', '`cr7` or `cr7museumstore`'],
          ['url', 'yes', 'Product URL — must contain `/produto/<slug>/`'],
          ['email', 'yes', 'MUST be a fresh email — cr7 forces account creation at checkout; an email that already has an account is rejected (`registration-error-email-exists`)'],
          ['first_name / last_name', 'yes', 'Billing identity'],
          ['postal_code / city / street', 'yes', 'Any address (defaults to a CZ address if blank)'],
          ['sizes', 'no', 'Variation token for variable products (e.g. `preto`); empty → first available variation'],
          ['payment_method', 'no', '`creditcard_checkout` (default) / `mbway_checkout` / `multibanco_checkout` / …'],
        ],
        flow: [
          'GET `/?rest_route=/wc/store/v1/cart` → seed Nonce + Cart-Token',
          'GET `/?rest_route=/wc/store/v1/products&slug=<slug>` → resolve product, pick variation',
          'POST `/?rest_route=/wc/store/v1/cart/add-item` (variation child id direct)',
          'POST `/?rest_route=/wc/store/v1/cart/update-customer` (billing + shipping)',
          'POST `/?rest_route=/wc/store/v1/checkout` → order_id + EasyPay pay link',
        ],
        sample: `cr7, https://cr7museumstore.com/produto/..., 1x, eu_main, none\ncr7, https://cr7museumstore.com/produto/..., 1x, eu_main, none ; sizes=preto`,
        troubleshooting: [
          ['`email already has an account — use a fresh email`', 'cr7 forces account creation at checkout. Guest order with an email that already registered is rejected. Use a brand-new email for the task.'],
          ['`woocommerce_rest_missing_attributes`', 'Variable product without a resolvable variation. Set the `sizes` token (e.g. `preto`) to match the wanted colour/variant.'],
          ['HTTP 400 on checkout but order_id present', 'Gateway leg pending — bot still succeeds and webhooks the EasyPay pay link.'],
        ],
      },
    ],
  }
};

// One subguide block — same fields a top-level guide uses, but rendered
// inline under an H2 with the subguide name. Used for the Lowkey bundle
// where Reflexshop + Cardverse sit stacked under one site entry.
function SubGuide({ sub }) {
  return (
    <>
      <h2 style={{ marginTop: 28 }}>{sub.name}</h2>
      {sub.notes && (
        <ul style={{ paddingLeft: 18, margin: '0 0 16px' }}>
          {sub.notes.map((n, i) =>
            <li key={i} style={{ marginBottom: 6 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
          )}
        </ul>
      )}
      {sub.modes && (
        <>
          <h3>Modes</h3>
          <table className="grid-table" style={{ marginBottom: 16 }}>
            <thead><tr><th>Mode</th><th>What it does</th></tr></thead>
            <tbody>
              {sub.modes.map(([m, d], i) => (
                <tr key={i}>
                  <td><code>{m}</code></td>
                  <td dangerouslySetInnerHTML={{ __html: d.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                </tr>
              ))}
            </tbody>
          </table>
        </>
      )}
      {sub.shipping && (
        <>
          <h3>Shipping</h3>
          <ul style={{ paddingLeft: 18, margin: '0 0 16px' }}>
            {sub.shipping.map((n, i) =>
              <li key={i} style={{ marginBottom: 6 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
            )}
          </ul>
        </>
      )}
      {sub.payment && (
        <>
          <h3>Payment</h3>
          <ul style={{ paddingLeft: 18, margin: '0 0 16px' }}>
            {sub.payment.map((n, i) =>
              <li key={i} style={{ marginBottom: 6 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
            )}
          </ul>
        </>
      )}
      {sub.csv_columns && (
        <>
          <h3>CSV columns</h3>
          <table className="grid-table" style={{ marginBottom: 16 }}>
            <thead><tr><th>Column</th><th>Required?</th><th>Notes</th></tr></thead>
            <tbody>
              {sub.csv_columns.map(([col, req, d], i) => (
                <tr key={i}>
                  <td><code>{col}</code></td>
                  <td>{req}</td>
                  <td dangerouslySetInnerHTML={{ __html: d.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                </tr>
              ))}
            </tbody>
          </table>
        </>
      )}
      {sub.flow && (
        <>
          <h3>Checkout flow</h3>
          <ol style={{ paddingLeft: 22, margin: '0 0 16px' }}>
            {sub.flow.map((n, i) =>
              <li key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
            )}
          </ol>
        </>
      )}
      {sub.sample && (
        <>
          <h3>CSV sample</h3>
          <pre><code>{sub.sample}</code></pre>
        </>
      )}
      {sub.troubleshooting && (
        <>
          <h3>Troubleshooting</h3>
          <table className="grid-table" style={{ marginBottom: 16 }}>
            <thead><tr><th>Symptom</th><th>Fix</th></tr></thead>
            <tbody>
              {sub.troubleshooting.map(([sym, fix], i) => (
                <tr key={i}>
                  <td dangerouslySetInnerHTML={{ __html: sym.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                  <td dangerouslySetInnerHTML={{ __html: fix.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                </tr>
              ))}
            </tbody>
          </table>
        </>
      )}
    </>
  );
}

function SiteGuide({ code }) {
  const s = siteByCode(code);
  const g = SITE_GUIDES[code];
  if (!g) return <div><h1>{s.name}</h1><p className="muted">No guide written yet.</p></div>;
  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
        <div className="mon-icon" style={{ width: 44, height: 44, fontSize: 14, color: s.color, borderColor: s.color + '40' }}>{s.name.slice(0, 2).toUpperCase()}</div>
        <div>
          <h1 style={{ margin: 0 }}>{s.name}</h1>
          <div style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)', letterSpacing: '0.14em', textTransform: 'uppercase', marginTop: 4 }}>{s.region}{g.register ? ' · register supported' : ''}{g.buy ? ' · buy' : ''}</div>
        </div>
      </div>

      <h2>Region / variants</h2>
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
        {g.regions.map((r) => <span key={r} className="filter-pill" style={{ cursor: 'default' }}>{r}</span>)}
      </div>

      <h2>Accepted task inputs</h2>
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
        {g.inputs.map((i) => <span key={i} className="filter-pill active" style={{ cursor: 'default' }}>{i}</span>)}
      </div>

      <h2>Notes</h2>
      <ul style={{ paddingLeft: 18, margin: '0 0 16px' }}>
        {g.notes.map((n, i) =>
          // safe: constant — `n` comes from the static SITE_DOCS notes
          // arrays defined in this file (not user-controlled). The
          // regex only converts `…` backtick spans into <code>…</code>.
          <li key={i} style={{ marginBottom: 6 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
        )}
      </ul>

      {g.modes && (
        <>
          <h2>Modes</h2>
          <table className="grid-table" style={{ marginBottom: 16 }}>
            <thead><tr><th>Mode</th><th>What it does</th></tr></thead>
            <tbody>
              {g.modes.map(([m, d], i) => (
                <tr key={i}>
                  <td><code>{m}</code></td>
                  <td dangerouslySetInnerHTML={{ __html: d.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                </tr>
              ))}
            </tbody>
          </table>
        </>
      )}

      {g.shipping && (
        <>
          <h2>Shipping</h2>
          <ul style={{ paddingLeft: 18, margin: '0 0 16px' }}>
            {g.shipping.map((n, i) =>
              <li key={i} style={{ marginBottom: 6 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
            )}
          </ul>
        </>
      )}

      {g.payment && (
        <>
          <h2>Payment</h2>
          <ul style={{ paddingLeft: 18, margin: '0 0 16px' }}>
            {g.payment.map((n, i) =>
              <li key={i} style={{ marginBottom: 6 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
            )}
          </ul>
        </>
      )}

      {g.csv_columns && (
        <>
          <h2>CSV columns</h2>
          <table className="grid-table" style={{ marginBottom: 16 }}>
            <thead><tr><th>Column</th><th>Required?</th><th>Notes</th></tr></thead>
            <tbody>
              {g.csv_columns.map(([col, req, d], i) => (
                <tr key={i}>
                  <td><code>{col}</code></td>
                  <td>{req}</td>
                  <td dangerouslySetInnerHTML={{ __html: d.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                </tr>
              ))}
            </tbody>
          </table>
        </>
      )}

      {g.flow && (
        <>
          <h2>Checkout flow</h2>
          <ol style={{ paddingLeft: 22, margin: '0 0 16px' }}>
            {g.flow.map((n, i) =>
              <li key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: n.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
            )}
          </ol>
        </>
      )}

      {g.sample && (
        <>
          <h2>CSV sample</h2>
          <pre><code>{g.sample}</code></pre>
        </>
      )}

      {g.subguides && g.subguides.map((sub, i) => (
        <SubGuide key={i} sub={sub} />
      ))}

      {g.troubleshooting && (
        <>
          <h2>Troubleshooting</h2>
          <table className="grid-table" style={{ marginBottom: 16 }}>
            <thead><tr><th>Symptom</th><th>Fix</th></tr></thead>
            <tbody>
              {g.troubleshooting.map(([sym, fix], i) => (
                <tr key={i}>
                  <td dangerouslySetInnerHTML={{ __html: sym.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                  <td dangerouslySetInnerHTML={{ __html: fix.replace(/`([^`]+)`/g, '<code>$1</code>') }} />
                </tr>
              ))}
            </tbody>
          </table>
        </>
      )}

      <div className="note">
        For the canonical writeup, see the <a className="ext" href={`https://bohmaan.github.io/HopAIO-docs/sites/${code}`} target="_blank" rel="noopener">public docs entry</a>.
      </div>
    </>);

}
function PaymentModesDoc() {
  return (
    <>
      <h1>Payment modes</h1>
      <p className="muted">A few sites accept alternative payment methods by overloading the <code>card_number</code> CSV column with a keyword instead of a real PAN. See the <a className="ext" href="https://bohmaan.github.io/HopAIO-docs/guide/csv-format#payment-selection-card_number" target="_blank" rel="noopener">public docs entry</a> for the canonical version.</p>

      <h2>The <code>card_number</code> table</h2>
      <table>
        <thead><tr><th>Value</th><th>Effect</th><th>Supported on</th></tr></thead>
        <tbody>
          <tr><td><code>4111…</code></td><td>Standard card checkout</td><td>All card-supporting sites</td></tr>
          <tr><td><code>gpay</code> / <code>googlepay</code></td><td>Google Pay handoff (URL in webhook)</td><td>Mediaexpert</td></tr>
          <tr><td><code>cod</code> / <code>pobranie</code> / <code>zapobraniem</code> / <code>cashondelivery</code></td><td>Cash on Delivery — courier brings goods, pay cash on arrival</td><td>Mediaexpert, Footshop</td></tr>
          <tr><td><code>instore</code> / <code>odbior</code> / <code>pickup</code></td><td>Pick up at physical store, pay cash at register</td><td>Mediaexpert</td></tr>
          <tr><td><code>instorecod</code> / <code>instorecard</code></td><td>Pick up at physical store, pay by card at register</td><td>Mediaexpert</td></tr>
        </tbody>
      </table>

      <h2>Store selection for in-store pickup</h2>
      <p>With <code>instore</code> / <code>instorecod</code> the bot resolves a specific physical store. Lookup order:</p>
      <ol>
        <li><strong><code>discount</code> CSV column</strong> → integer POS id (per-task override).</li>
        <li><strong><code>config.json</code> → <code>mediaexpert_default_pos_id</code></strong> → global default.</li>
        <li><strong>env <code>HOP_ME_DEFAULT_POS_ID</code></strong> → CI / one-shot override.</li>
        <li><strong>Automatic</strong> → <code>GET /api/pos/list/search_nearest_by_cart</code> picks the store nearest the CSV <code>postal_code</code>.</li>
      </ol>

      <h2>Example: Mediaexpert COD</h2>
      <pre><code>{`mediaexpert;https://www.mediaexpert.pl/product,id123;buy;1;;5000;3;guest@example.com;;;John;Doe;+48123456789;Marszalkowska;100;Warszawa;00-001;mazowieckie;PL;cod;;;`}</code></pre>

      <h2>Example: Mediaexpert in-store pickup, pay by card (auto store)</h2>
      <pre><code>{`mediaexpert;https://www.mediaexpert.pl/product,id123;buy;1;;5000;3;guest@example.com;;;John;Doe;+48123456789;Marszalkowska;100;Warszawa;00-001;mazowieckie;PL;instorecod;;;`}</code></pre>

      <div className="note">For the Mediaexpert <code>payment_id</code> reference table (41 COD, 45 BLIK, 61 Card, 529 Apple Pay, 538 Google Pay, …) see <a className="ext" href="https://bohmaan.github.io/HopAIO-docs/sites/mediaexpert#payment-methods" target="_blank" rel="noopener">Mediaexpert — Payment methods</a>.</div>
    </>);

}

function TelemetryDoc() {
  return (
    <>
      <h1>Telemetry & webhooks</h1>
      <p className="muted">How HopAIO reports its lifecycle and checkouts. Rewritten in v2.1.18 — one server endpoint replaces the old per-event Discord webhook fan-out from the bot.</p>

      <h2>Flow</h2>
      <pre><code>{`bot ──POST /v1/events──▶ license server ┬─▶ public webhook   (checkout_success only)
                                        ├─▶ user webhook     (checkout_success only)
                                        ├─▶ ops webhook      (cli_*, batch_*, ops, cli_offline)
                                        └─▶ admin dashboard  (every event, persisted)`}</code></pre>
      <p>Failures are <strong>never</strong> webhook'd anywhere — they surface only in your local console and the admin Events Feed.</p>

      <h2>Event types</h2>
      <table>
        <thead><tr><th>Event</th><th>When</th><th>Discord destination</th></tr></thead>
        <tbody>
          <tr><td><code>cli_started</code></td><td>Bot launches, after license check</td><td>Ops (green)</td></tr>
          <tr><td><code>heartbeat</code></td><td>Every 10 s while running</td><td>— (refreshes session)</td></tr>
          <tr><td><code>batch_started</code></td><td>One per CSV / QT batch</td><td>Ops (blue)</td></tr>
          <tr><td><code>batch_finished</code></td><td>One per batch when done</td><td>Ops (green)</td></tr>
          <tr><td><code>checkout_success</code></td><td>Each successful order</td><td>Public + user webhook</td></tr>
          <tr><td><code>cli_stopped</code></td><td>atexit best-effort</td><td>Ops (gray)</td></tr>
          <tr><td><code>cli_offline</code></td><td>Server-synthetic when heartbeat missed</td><td>Ops (red)</td></tr>
          <tr><td><code>ops</code></td><td>Operator alerts (license, crash, sync)</td><td>Ops</td></tr>
        </tbody>
      </table>

      <h2><code>cli_started</code> embed shape (v2.1.20)</h2>
      <pre><code>{`CLI started                                                       🟢
bohmaan • <machine_id> • v2.1.20 • tier: lifetime • ⚡ QT

System         Windows 10 • Py 3.14.0
Server         https://bohmannm.com
CSV files (3)  \`media.csv\`, \`game.csv\`, \`sv.csv\`
Proxies        120 (\`proxylist.txt\`)
Webhooks       user ✓, raffle ✓`}</code></pre>

      <h2><code>batch_started</code> / <code>batch_finished</code></h2>
      <pre><code>{`Batch started (150 tasks)                                         🔵
bohmaan • 150 task(s) • trigger: csv • source: media.csv

Sites          mediaexpert:120, alza:30
Modes          Normal:80, Auto:50, COD:15, InStore:5
Proxies        120 (proxylist.txt)`}</code></pre>

      <h2>Configuration</h2>
      <h3>CLI side (<code>config.json</code>)</h3>
      <pre><code>{`{
  "license_key": "…",
  "webhook_url": "https://discord.com/api/webhooks/…",
  "raffle_webhook_url": "https://discord.com/api/webhooks/…",
  "monitor_mode": false,
  "license_server_url": "https://bohmannm.com"
}`}</code></pre>
      <p><code>webhook_url</code> is the only webhook the CLI ever posts to directly — and only for <code>checkout_success</code>. Everything else goes through <code>/v1/events</code>.</p>

      <h3>Server env vars</h3>
      <table>
        <thead><tr><th>Var</th><th>Purpose</th><th>Default</th></tr></thead>
        <tbody>
          <tr><td><code>HOP_PUBLIC_WEBHOOK_URL</code></td><td>Public success channel</td><td>unset → no dispatch</td></tr>
          <tr><td><code>HOP_OPS_SPAM_WEBHOOK</code></td><td>Operator lifecycle / ops alerts</td><td>unset → no dispatch</td></tr>
          <tr><td><code>HOP_SESSION_OFFLINE_AFTER_S</code></td><td>Seconds before reaper marks online sessions offline</td><td>25</td></tr>
          <tr><td><code>HOP_EVENTS_PER_SESSION_CAP</code></td><td>Max events retained per session</td><td>2000</td></tr>
          <tr><td><code>DISCORD_CLIENT_ID</code> / <code>DISCORD_CLIENT_SECRET</code></td><td>Admin panel OAuth login</td><td>unset → 503</td></tr>
          <tr><td><code>ADMIN_EMAIL</code></td><td>Whose Discord email may sign in to admin</td><td>bohmanneu@gmail.com</td></tr>
        </tbody>
      </table>

      <h2>Reliability</h2>
      <p>The CLI's event emitter is fire-and-forget. A background worker drains the in-memory queue and POSTs to <code>/v1/events</code>; when unreachable, events spool to <code>dashboard/data/events_buffer.jsonl</code> (5000-line cap, oldest dropped). Buffer drains on next successful POST.</p>
      <div className="note">If you see a non-empty buffer file for more than a few minutes, the bot can't reach the license server (firewall, DNS, expired license). Network issues never block checkouts — telemetry is best-effort.</div>

      <h2>Admin dashboard tabs (Discord OAuth required)</h2>
      <ul>
        <li><strong>Live Sessions</strong> — every running bot with status pill, last-heartbeat, active-tasks counter. Refreshes every 8 s.</li>
        <li><strong>Events Feed</strong> — last 300 events with type filter chips + free-text search.</li>
        <li><strong>Download Bot</strong> — streams the latest release EXE.</li>
      </ul>

      <div className="note">Canonical writeup: <a className="ext" href="https://bohmaan.github.io/HopAIO-docs/guide/telemetry" target="_blank" rel="noopener">Telemetry & webhooks (public docs)</a>.</div>
    </>);

}

function TroubleshootingDoc() {
  return (
    <>
      <h1>Troubleshooting</h1>
      <h2>Bot won't start</h2>
      <p>Check that you've added a Defender exclusion and that <code>hopaio.exe</code> is in a user-writable folder.</p>
      <h2>License key invalid</h2>
      <p>Keys are case-sensitive. Make sure there are no leading/trailing spaces. Contact support in the private Discord if the key is genuinely rejected.</p>
      <h2>Captcha solver failures</h2>
      <p>Check API balance for your configured solver. Manual fallback is enabled by default.</p>
      <div className="note">Still stuck? File an issue on <a className="ext" href="https://github.com/bohmaan/HopAIO" target="_blank">GitHub</a> or open a ticket in the private Discord.</div>
    </>);

}

// ---------- ROOT ----------
// One stub card per site so the monitoring grid is always populated even
// before the bot uploads its first snapshot. The Edit button on each stub
// then lets the user submit a URL → server → admin queue.
const SITE_REGIONS = {
  alza: ['CZ', 'DE', 'AT', 'HU'],
  solebox: ['DE', 'EU'],
  proshop: ['DE', 'AT', 'NL', 'DK'],
};
const SITE_PRICE_LIMIT = {
  empik: { default: 49, currency: 'PLN', hint: 'Pokémon TCG ETB ceiling' },
  gamesisland: { default: 159, currency: 'EUR', hint: 'Lorcana / TCG booster box ceiling' },
};
function defaultMonitorStubs() {
  return SITES.slice(0, 12).map((s) => {
    const regions = SITE_REGIONS[s.code] || null;
    const pl = SITE_PRICE_LIMIT[s.code];
    return {
      site: s.code,
      regions,
      region: regions ? regions[0] : null,
      links: 0,
      tasksConnected: 0,
      priceLimit: pl ? { value: pl.default, currency: pl.currency, hint: pl.hint } : null,
      lastRestock: '— never',
      live: false,
      url: '',
      urlStatus: 'pending',
      watchlist: [],
    };
  });
}

// ---------- TWEAKS DEFAULTS ----------
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "mood": "ops",
  "density": "comfort",
  "chrome": "console"
} /*EDITMODE-END*/;

function readStoredKey() {
  return normalizeKey(localStorage.getItem('hop_key') || '');
}

function App() {
  const [authed, setAuthed] = useState(() => !!localStorage.getItem('hop_key'));
  const [licenseKey, setLicenseKey] = useState(readStoredKey);
  const [token, setToken] = useState(() => localStorage.getItem('hop_token') || '');
  const [view, setView] = useState(() => localStorage.getItem('hop_view') || 'dashboard');
  const [tasks, setTasks] = useState([]);
  const [taskGroups, setTaskGroups] = useState([]);
  const [checkouts, setCheckouts] = useState([]);
  const [monitors, setMonitors] = useState(() => defaultMonitorStubs());
  // Listener status reflects the bot's heartbeat row on the server. We
  // can't start/stop the bot from the browser; the toggle button just
  // re-polls so the user can confirm the live state without waiting for
  // the next interval tick.
  const [qtConnected, setQtConnected] = useState(false);
  const [listenerLinks, setListenerLinks] = useState(0);
  const refreshListenerStatus = useCallback(async () => {
    if (!token) { setQtConnected(false); setListenerLinks(0); return; }
    try {
      const data = await api('/v1/dashboard/listener/status', { token });
      setQtConnected(!!data.connected);
      setListenerLinks(Number(data.links || 0));
    } catch (e) { setQtConnected(false); setListenerLinks(0); }
  }, [token]);
  useEffect(() => {
    if (!token) return;
    refreshListenerStatus();
    const t = setInterval(refreshListenerStatus, 5000);
    return () => clearInterval(t);
  }, [token, refreshListenerStatus]);

  // Monitoring snapshot from /v1/dashboard/monitors (the bot uploads its
  // build_monitors_snapshot output after every rebuild). Server data is
  // merged into the 12-site stub grid so cards stay visible even when the
  // server has nothing for a particular site.
  const reloadMonitors = useCallback(async () => {
    if (!token) return;
    try {
      const data = await api('/v1/dashboard/monitors', { token });
      if (!data || !Array.isArray(data.monitors)) return;
      const bySite = {};
      for (const m of data.monitors) bySite[m.site] = m;
      setMonitors(defaultMonitorStubs().map((stub) => bySite[stub.site] ? { ...stub, ...bySite[stub.site] } : stub));
    } catch (e) { /* keep stubs on miss */ }
  }, [token]);

  useEffect(() => {
    if (!authed || !token) return;
    reloadMonitors();
    hydrateQtCsv(token);
  }, [licenseKey, token, authed, reloadMonitors]);
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // Pull this license key's checkout history from the server. 404 → empty.
  // Any fetch failure → empty list, never crash the dashboard.
  const reloadCheckouts = useCallback(async () => {
    if (!licenseKey || !token) { setCheckouts([]); return; }
    try {
      const data = await api('/v1/dashboard/checkouts', { token });
      const items = (data.checkouts || []).slice().reverse();
      setCheckouts(items);
    } catch (e) {
      setCheckouts([]);
    }
  }, [licenseKey, token]);

  useEffect(() => { reloadCheckouts(); }, [reloadCheckouts]);

  // Per-CSV task groups (with proxy file + per-task status). Polls every
  // 5s while the Tasks view is open so the user sees status transitions
  // (queued -> running -> success/exception) in near-real-time.
  const reloadTaskGroups = useCallback(async () => {
    if (!licenseKey || !token) { setTaskGroups([]); return; }
    try {
      const data = await api('/v1/dashboard/tasks', { token });
      setTaskGroups(Array.isArray(data.groups) ? data.groups : []);
    } catch (e) {
      setTaskGroups([]);
    }
  }, [licenseKey, token]);

  const [uploads, setUploads] = useState([]);
  const reloadUploads = useCallback(async () => {
    if (!licenseKey || !token) { setUploads([]); return; }
    try {
      const data = await api('/v1/dashboard/uploads', { token });
      setUploads(Array.isArray(data.uploads) ? data.uploads : []);
    } catch (e) {
      setUploads([]);
    }
  }, [licenseKey, token]);

  useEffect(() => { reloadUploads(); }, [reloadUploads]);

  const uploadFile = useCallback(async (kind, name, content_b64) => {
    if (!token) throw new Error('not_authenticated');
    await api('/v1/dashboard/uploads', {
      method: 'POST', body: { kind, name, content_b64 }, token,
    });
    await reloadUploads();
  }, [token, reloadUploads]);

  const deleteUpload = useCallback(async (kind, name) => {
    if (!token) throw new Error('not_authenticated');
    await api(`/v1/dashboard/uploads/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`, {
      method: 'DELETE', token,
    });
    await reloadUploads();
  }, [token, reloadUploads]);

  const startSession = useCallback(async (sid, csv, proxy, csvLocal, proxyLocal) => {
    if (!token) throw new Error('not_authenticated');
    await api(`/v1/dashboard/sessions/${encodeURIComponent(sid)}/start`, {
      method: 'POST',
      body: {
        csv,
        proxy: proxy || '',
        csv_local: !!csvLocal,
        proxy_local: !!proxyLocal,
      },
      token,
    });
    await reloadTaskGroups();
  }, [token, reloadTaskGroups]);

  // Fetcher for the bot's reported local file inventory. Called from
  // RemoteStartPanel when a CLI is selected so the user sees what's
  // already on the bot machine.
  const fetchLocalFiles = useCallback(async (sid) => {
    if (!token) return { csvs: [], proxies: [] };
    try {
      return await api(`/v1/dashboard/local-files/${encodeURIComponent(sid)}`, { token });
    } catch (e) {
      return { csvs: [], proxies: [] };
    }
  }, [token]);

  // Diagnostic: most recent task_print events for verifying the bot is
  // actually mirroring evt() lines to the server.
  const [taskPrintDebug, setTaskPrintDebug] = useState([]);
  const reloadTaskPrintDebug = useCallback(async () => {
    if (!token) { setTaskPrintDebug([]); return; }
    try {
      const data = await api('/v1/dashboard/task-prints?limit=30', { token });
      setTaskPrintDebug(Array.isArray(data.task_prints) ? data.task_prints : []);
    } catch (e) {
      setTaskPrintDebug([]);
    }
  }, [token]);

  const killSession = useCallback(async (sid) => {
    if (!token) throw new Error('not_authenticated');
    await api(`/v1/dashboard/sessions/${encodeURIComponent(sid)}/kill`, {
      method: 'POST', token,
    });
    await reloadTaskGroups();
  }, [token, reloadTaskGroups]);

  useEffect(() => { reloadTaskGroups(); }, [reloadTaskGroups]);
  useEffect(() => {
    if (view !== 'tasks' || !token) return;
    // 2s tick keeps "Last print" feeling live without flooding the API
    // (and matches the bot-side TASK_PRINT debounce window).
    const t = setInterval(reloadTaskGroups, 2000);
    return () => clearInterval(t);
  }, [view, token, reloadTaskGroups]);

  // Mutate the per-license pending-links list, then refetch so the
  // canonical state from the server replaces any optimistic UI.
  const addLink = useCallback(async ({ site, url, region }) => {
    if (!token) throw new Error('not_authenticated');
    await api('/v1/dashboard/monitors/submit', {
      method: 'POST', body: { site, url, region }, token,
    });
    await reloadMonitors();
  }, [token, reloadMonitors]);

  const deleteLink = useCallback(async (linkId) => {
    if (!token) throw new Error('not_authenticated');
    await api(`/v1/dashboard/monitors/${encodeURIComponent(linkId)}`, {
      method: 'DELETE', token,
    });
    await reloadMonitors();
  }, [token, reloadMonitors]);

  useEffect(() => {localStorage.setItem('hop_view', view);}, [view]);
  useEffect(() => {
    const r = document.documentElement;
    r.dataset.mood = t.mood;
    r.dataset.density = t.density;
    r.dataset.chrome = t.chrome;
  }, [t.mood, t.density, t.chrome]);

  const login = (k, tok) => {
    localStorage.setItem('hop_key', k);
    if (tok) localStorage.setItem('hop_token', tok);
    else localStorage.removeItem('hop_token');
    setLicenseKey(k);
    setToken(tok || '');
    setAuthed(true);
  };
  const logout = () => {
    localStorage.removeItem('hop_key');
    localStorage.removeItem('hop_token');
    setAuthed(false);
    setLicenseKey('');
    setToken('');
  };

  if (!authed) return <Login onAuth={login} />;

  return (
    <div className="app">
      <div className="grid-bg"></div>
      {t.chrome === 'arcade' && <div className="scanlines"></div>}
      <Sidebar view={view} setView={setView} licenseKey={licenseKey} onLogout={logout} />
      <main className="main">
        {view === 'dashboard' && <DashboardView tasks={tasks} checkouts={checkouts} monitors={monitors} />}
        {view === 'tasks' && <TasksView
          groups={taskGroups}
          uploads={uploads}
          onRefresh={reloadTaskGroups}
          onUpload={uploadFile}
          onDeleteUpload={deleteUpload}
          onStart={startSession}
          onKill={killSession}
          onFetchLocalFiles={fetchLocalFiles}
          taskPrintDebug={taskPrintDebug}
          onReloadTaskPrintDebug={reloadTaskPrintDebug} />}
        {view === 'checkout' && <CheckoutView tasks={tasks} checkouts={checkouts} onRefresh={reloadCheckouts} licenseKey={licenseKey} />}
        {view === 'monitor' && <MonitorView monitors={monitors} setMonitors={setMonitors} qtConnected={qtConnected} listenerLinks={listenerLinks} onRefreshListener={refreshListenerStatus} onAddLink={addLink} onDeleteLink={deleteLink} onRefresh={reloadMonitors} />}
        {view === 'docs' && <DocsView />}
        {view === 'settings' && <SettingsView licenseKey={licenseKey} qtConnected={qtConnected} listenerLinks={listenerLinks} onRefreshListener={refreshListenerStatus} />}
      </main>
      <TweaksPanel title="Tweaks">
        <TweakSection title="Mood" subtitle="Accent intensity & glow">
          <TweakRadio
            value={t.mood}
            onChange={(v) => setTweak('mood', v)}
            options={[
            { value: 'stealth', label: 'Stealth' },
            { value: 'ops', label: 'Ops' },
            { value: 'matrix', label: 'Matrix' }]
            } />
          
        </TweakSection>
        <TweakSection title="Density" subtitle="How much air the layout breathes">
          <TweakRadio
            value={t.density}
            onChange={(v) => setTweak('density', v)}
            options={[
            { value: 'compact', label: 'Compact' },
            { value: 'comfort', label: 'Comfort' },
            { value: 'roomy', label: 'Roomy' }]
            } />
          
        </TweakSection>
        <TweakSection title="Chrome" subtitle="Background grid + extras">
          <TweakRadio
            value={t.chrome}
            onChange={(v) => setTweak('chrome', v)}
            options={[
            { value: 'minimal', label: 'Minimal' },
            { value: 'console', label: 'Console' },
            { value: 'arcade', label: 'Arcade' }]
            } />
          
        </TweakSection>
      </TweaksPanel>
    </div>);

}

// ---------- TASKS ----------
// One row per parsed CSV task, grouped by (session_id + csv_source). Each
// group header shows the CSV filename and the proxy file used for that run.
// Status flips queued -> running -> completed/exception, with success
// overriding when /v1/events catches a checkout_success for that task URL.
function TasksView({ groups: rawGroups, uploads = [], onRefresh,
                    onUpload, onDeleteUpload, onStart, onKill,
                    onFetchLocalFiles, taskPrintDebug = [],
                    onReloadTaskPrintDebug }) {
  // Groups are now per-online-CLI (finished sessions filtered server-side).
  // Top row: CLI selector pills. Body: expanded task list of the selected CLI.
  // Defensive client-side sort by started_at ascending — server already
  // does this, but a Railway cold-cache hit or stale response shouldn't
  // shuffle the operator's pill order. Tasks already sort by task_id
  // server-side; we don't touch that here.
  const groups = useMemo(() => {
    const arr = Array.isArray(rawGroups) ? rawGroups.slice() : [];
    arr.sort((a, b) => (a.started_at || 0) - (b.started_at || 0));
    return arr;
  }, [rawGroups]);

  const [selectedSid, setSelectedSid] = useState(null);

  // Auto-select first CLI when list changes; clear if selected CLI disappeared.
  useEffect(() => {
    if (!groups || groups.length === 0) {
      if (selectedSid !== null) setSelectedSid(null);
      return;
    }
    if (selectedSid === null || !groups.find((g) => g.session_id === selectedSid)) {
      setSelectedSid(groups[0].session_id);
    }
  }, [groups, selectedSid]);

  const fmtStarted = (ms) => {
    if (!ms) return '—';
    const d = new Date(ms);
    const today = new Date();
    const sameDay = d.toDateString() === today.toDateString();
    const time = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
    return sameDay ? `today ${time}` : `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')} ${time}`;
  };
  const fmtPrice = (p) => p && p > 0 ? `€ ${p}` : '—';
  const fmtSizeMax = (t) => {
    const parts = [];
    if (t.sizes && t.sizes.trim()) parts.push(t.sizes.trim());
    if (t.max_price && t.max_price > 0) parts.push(`≤ ${fmtPrice(t.max_price)}`);
    return parts.length ? parts.join(' · ') : '—';
  };
  const shortSid = (sid) => (sid || '').replace(/^sess_/, '').slice(0, 10);
  const sitesLabel = (sites) => {
    if (!sites || sites.length === 0) return 'no sites';
    if (sites.length <= 3) return sites.map((s) => s.name).join(' · ');
    return `${sites.slice(0, 2).map((s) => s.name).join(' · ')} +${sites.length - 2}`;
  };

  const selected = groups.find((g) => g.session_id === selectedSid) || null;

  return (
    <div className="view">
      <Topbar crumbs={['HopAIO', 'Tasks']} right={<>
        <span className="chip ok"><span className="dot"></span>{groups.length} CLI ONLINE</span>
        <button className="btn ghost" style={{ width: 'auto', margin: 0, padding: '8px 14px' }} onClick={onRefresh}><I.refresh /> REFRESH</button>
      </>} />
      <h1 className="page">Tasks</h1>
      <p className="page-sub">Live view of the tasks every online CLI is running. Pick a CLI above to expand its task list; the last bot print replaces the static status.</p>

      {/* CLI selector — top row of pills, one per online session */}
      {groups.length === 0 ? (
        <div className="card" style={{ padding: '40px 22px', textAlign: 'center', color: 'var(--muted)', fontFamily: 'var(--mono)', fontSize: 12, letterSpacing: '0.1em' }}>
          NO CLI ONLINE — start the bot and it'll show up here within a few seconds.
        </div>
      ) : (
        <div className="card" style={{ padding: '14px 16px', marginBottom: 18 }}>
          <div style={{ fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', marginBottom: 10 }}>CLI sessions</div>
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            {groups.map((g) => {
              const active = g.session_id === selectedSid;
              return (
                <button key={g.session_id}
                  onClick={() => setSelectedSid(g.session_id)}
                  style={{
                    cursor: 'pointer',
                    background: active ? 'var(--green-soft)' : 'transparent',
                    border: `1px solid ${active ? 'var(--green)' : 'var(--line)'}`,
                    borderRadius: 6,
                    padding: '10px 14px',
                    textAlign: 'left',
                    fontFamily: 'inherit',
                    color: 'var(--text)',
                    minWidth: 180,
                  }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
                    <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--green)', boxShadow: '0 0 6px var(--green)' }}></span>
                    <code style={{ fontFamily: 'var(--mono)', fontSize: 11, color: active ? 'var(--green)' : 'var(--text)' }}>{shortSid(g.session_id)}</code>
                    {g.version ? <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--dim)', letterSpacing: '0.1em' }}>v{g.version}</span> : null}
                  </div>
                  <div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '0.05em' }}>
                    {sitesLabel(g.sites)} · {g.tasks.length} task{g.tasks.length === 1 ? '' : 's'}
                  </div>
                </button>
              );
            })}
          </div>
        </div>
      )}

      {/* Diagnostic: latest task_print events the bot has actually
          mirrored to the server. If this stays empty when tasks are
          running, the bot isn't emitting (likely stale .exe build). */}
      {onReloadTaskPrintDebug && (
        <details className="card" style={{ marginBottom: 18, padding: '10px 14px' }}>
          <summary style={{ cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)', letterSpacing: '0.18em', textTransform: 'uppercase' }}>
            DIAG: Latest task_print events ({taskPrintDebug.length})
            <button className="btn ghost" onClick={(e) => { e.preventDefault(); e.stopPropagation(); onReloadTaskPrintDebug(); }}
                    style={{ width: 'auto', margin: '0 0 0 12px', padding: '3px 10px' }}>
              REFRESH
            </button>
          </summary>
          <div style={{ marginTop: 10, maxHeight: 260, overflowY: 'auto' }}>
            {taskPrintDebug.length === 0
              ? <div style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--red)', padding: '6px 0' }}>
                  No task_print events on record. Bot is either not emitting (rebuild needed) or the events aren't reaching the server.
                </div>
              : taskPrintDebug.map((d, i) => (
                  <div key={i} style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)', padding: '3px 0', display: 'flex', gap: 10 }}>
                    <span style={{ color: 'var(--dim)' }}>{new Date(d.ts).toLocaleTimeString()}</span>
                    <span style={{ color: 'var(--green)' }}>sid={String(d.session_id || '').slice(0, 10)}</span>
                    <span>tid={d.task_id}</span>
                    <span style={{ color: 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={d.last_print}>{d.last_print}</span>
                  </div>
                ))}
          </div>
        </details>
      )}

      {/* Selected CLI: expanded task list + remote control */}
      {selected && (() => {
        const g = selected;
        const idle = (g.tasks || []).length === 0;
        return (
          <div className="card" style={{ marginBottom: 22 }}>
            <div className="card-head" style={{ flexWrap: 'wrap', gap: 12 }}>
              <div>
                <h3 style={{ margin: 0, fontSize: 15, fontFamily: 'var(--mono)' }}>
                  <code style={{ color: 'var(--green)', background: 'var(--green-soft)', padding: '3px 8px', borderRadius: 4 }}>{g.session_id}</code>
                </h3>
                <div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', marginTop: 6, letterSpacing: '0.05em' }}>
                  started {fmtStarted(g.started_at)}
                  <span style={{ color: 'var(--dim)' }}> · </span>
                  {g.tasks.length} task{g.tasks.length === 1 ? '' : 's'}
                  {g.csv_sources && g.csv_sources.length > 0 ? <>
                    <span style={{ color: 'var(--dim)' }}> · </span>
                    csv: <span style={{ color: 'var(--text)' }}>{g.csv_sources.join(', ')}</span>
                  </> : null}
                  {g.proxy_files && g.proxy_files.length > 0 ? <>
                    <span style={{ color: 'var(--dim)' }}> · </span>
                    proxylist: <span style={{ color: 'var(--text)' }}>{g.proxy_files.join(', ')}</span>
                  </> : null}
                </div>
              </div>
              <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
                {(g.sites || []).map((s) => (
                  <span key={s.name} className="status-pill" style={{ color: 'var(--text)' }}>{s.name} · {s.count}</span>
                ))}
                <button
                  className="btn ghost"
                  style={{ width: 'auto', margin: 0, padding: '6px 14px', color: 'var(--red)', borderColor: 'var(--red)' }}
                  onClick={async () => {
                    if (!window.confirm(`Kill ${shortSid(g.session_id)}?`)) return;
                    try { await onKill(g.session_id); }
                    catch (e) { window.alert('Kill failed: ' + (e.message || e)); }
                  }}>
                  ✕ KILL
                </button>
              </div>
            </div>

            {idle && (
              <RemoteStartPanel
                sid={g.session_id}
                uploads={uploads}
                onStart={onStart}
                onUpload={onUpload}
                onDeleteUpload={onDeleteUpload}
                onFetchLocalFiles={onFetchLocalFiles} />
            )}
            <div style={{ overflowX: 'auto' }}>
              <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
                <thead><tr style={{ borderBottom: '1px solid var(--line)' }}>
                  <th style={{ textAlign: 'left', padding: '10px 12px 10px 18px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', fontWeight: 500, width: 60 }}>#</th>
                  <th style={{ textAlign: 'left', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', fontWeight: 500 }}>Site</th>
                  <th style={{ textAlign: 'left', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', fontWeight: 500 }}>Input</th>
                  <th style={{ textAlign: 'left', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', fontWeight: 500 }}>Size / Max</th>
                  <th style={{ textAlign: 'left', padding: '10px 18px 10px 12px', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', fontWeight: 500 }}>Last print</th>
                </tr></thead>
                <tbody>
                  {g.tasks.map((t) => {
                    const s = siteByCode(t.site);
                    const href = (typeof t.url === 'string' && /^https?:\/\//i.test(t.url.trim())) ? t.url.trim() : null;
                    const lp = t.last_print && t.last_print.trim() ? t.last_print : null;
                    const lpColor = lp
                      ? (/(success|order placed|added to cart|in stock|logged in|registered)/i.test(lp) ? 'var(--green)'
                        : /(fail|error|exception)/i.test(lp) ? 'var(--red)'
                        : /(out of stock|monitor|polling|retrying)/i.test(lp) ? '#e8c468'
                        : 'var(--text)')
                      : 'var(--dim)';
                    return (
                      <tr key={t.task_id} style={{ borderBottom: '1px solid rgba(26,38,32,0.5)' }}>
                        <td style={{ padding: '10px 12px 10px 18px', fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }}>#{String(t.task_id).padStart(3, '0')}</td>
                        <td style={{ padding: '10px 12px' }}>
                          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                            <span style={{ width: 8, height: 8, borderRadius: 2, background: s.color, boxShadow: `0 0 6px ${s.color}80` }}></span>
                            <span style={{ fontSize: 12 }}>{s.name}</span>
                          </div>
                        </td>
                        <td style={{ padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text)', maxWidth: 360, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={t.url}>
                          {href
                            ? <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text)', textDecoration: 'none' }}>{t.url} <I.ext /></a>
                            : <span style={{ color: 'var(--muted)' }}>{t.url || '—'}</span>}
                        </td>
                        <td style={{ padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }}>{fmtSizeMax(t)}</td>
                        <td style={{ padding: '10px 18px 10px 12px', fontFamily: 'var(--mono)', fontSize: 11, color: lpColor, maxWidth: 460, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={lp || ''}>
                          {lp || <span style={{ color: 'var(--dim)' }}>waiting…</span>}
                        </td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          </div>
        );
      })()}
    </div>
  );
}

// Remote-start UI for an idle CLI session: file upload + chooser + start
// button. Surfaced inside TasksView when the selected CLI has zero tasks.
function RemoteStartPanel({ sid, uploads = [], onStart, onUpload,
                            onDeleteUpload, onFetchLocalFiles }) {
  const uploadCsvs = uploads.filter((u) => u.kind === 'csv');
  const uploadProxies = uploads.filter((u) => u.kind === 'proxylist');

  // Local files reported by the bot — refreshed every ~5s while the
  // panel is mounted so a freshly-dropped CSV shows up promptly.
  const [localFiles, setLocalFiles] = useState({ csvs: [], proxies: [] });
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      if (!onFetchLocalFiles || !sid) return;
      const data = await onFetchLocalFiles(sid);
      if (alive) setLocalFiles({
        csvs: Array.isArray(data.csvs) ? data.csvs : [],
        proxies: Array.isArray(data.proxies) ? data.proxies : [],
      });
    };
    tick();
    const t = setInterval(tick, 5000);
    return () => { alive = false; clearInterval(t); };
  }, [sid, onFetchLocalFiles]);

  // Selection: `kind` is "local" | "upload"; `name` is the filename.
  // null means "no selection" (CSV must be picked; proxy can be null
  // for direct connection).
  const [csvPick, setCsvPick] = useState(null);
  const [proxyPick, setProxyPick] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  // Auto-select first available CSV (prefer local; fall back to upload).
  useEffect(() => {
    if (csvPick) {
      const exists = csvPick.kind === 'local'
        ? localFiles.csvs.find((u) => u.name === csvPick.name)
        : uploadCsvs.find((u) => u.name === csvPick.name);
      if (exists) return;
    }
    if (localFiles.csvs.length > 0) {
      setCsvPick({ kind: 'local', name: localFiles.csvs[0].name });
    } else if (uploadCsvs.length > 0) {
      setCsvPick({ kind: 'upload', name: uploadCsvs[0].name });
    } else {
      setCsvPick(null);
    }
  }, [localFiles.csvs, uploadCsvs, csvPick]);

  useEffect(() => {
    if (!proxyPick) return;
    const exists = proxyPick.kind === 'local'
      ? localFiles.proxies.find((u) => u.name === proxyPick.name)
      : uploadProxies.find((u) => u.name === proxyPick.name);
    if (!exists) setProxyPick(null);
  }, [localFiles.proxies, uploadProxies, proxyPick]);

  const upload = async (e, kind) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    if (f.size > 2 * 1024 * 1024) {
      setErr('File too large (max 2 MB)');
      return;
    }
    setErr('');
    try {
      const buf = await f.arrayBuffer();
      // btoa requires a string; convert ArrayBuffer to binary string in chunks
      // so large files don't blow the call stack.
      let binary = '';
      const bytes = new Uint8Array(buf);
      const chunk = 32768;
      for (let i = 0; i < bytes.length; i += chunk) {
        binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
      }
      const b64 = window.btoa(binary);
      await onUpload(kind, f.name, b64);
      e.target.value = '';
    } catch (ex) {
      setErr('Upload failed: ' + (ex.message || ex));
    }
  };

  const start = async () => {
    if (!csvPick) { setErr('Pick a CSV first.'); return; }
    setBusy(true);
    setErr('');
    try {
      await onStart(sid, csvPick.name,
                    proxyPick ? proxyPick.name : '',
                    csvPick.kind === 'local',
                    proxyPick && proxyPick.kind === 'local');
    } catch (ex) {
      setErr('Start failed: ' + (ex.message || ex));
    } finally {
      setBusy(false);
    }
  };

  const fmtBytes = (n) => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n/1024).toFixed(1)} KB` : `${(n/1024/1024).toFixed(2)} MB`;

  const csvSelected = (kind, name) =>
    csvPick && csvPick.kind === kind && csvPick.name === name;
  const proxySelected = (kind, name) =>
    proxyPick && proxyPick.kind === kind && proxyPick.name === name;

  return (
    <div style={{ padding: '20px 24px', borderTop: '1px solid var(--line)', background: 'rgba(26,38,32,0.25)' }}>
      <div style={{ fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dim)', marginBottom: 8 }}>
        Remote start
      </div>
      <p style={{ margin: '0 0 14px 0', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
        This CLI is idle. Upload a CSV + (optional) proxylist below, pick them, and click START — the bot will fetch them and run tasks remotely.
      </p>

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, marginBottom: 14 }}>
        {/* CSV column */}
        <div>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
            <span style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)', letterSpacing: '0.14em', textTransform: 'uppercase' }}>CSV</span>
            <label style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--green)', cursor: 'pointer', letterSpacing: '0.1em' }}>
              + UPLOAD
              <input type="file" accept=".csv" style={{ display: 'none' }} onChange={(e) => upload(e, 'csv')} />
            </label>
          </div>
          {localFiles.csvs.length > 0 && (
            <div style={{ marginBottom: 8 }}>
              <div style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--green)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 3 }}>On bot machine ({localFiles.csvs.length})</div>
              {localFiles.csvs.map((u) => (
                <div key={'l-' + u.name} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 4, background: csvSelected('local', u.name) ? 'var(--green-soft)' : 'transparent', cursor: 'pointer', marginBottom: 2 }} onClick={() => setCsvPick({ kind: 'local', name: u.name })}>
                  <input type="radio" checked={csvSelected('local', u.name)} readOnly style={{ accentColor: 'var(--green)' }} />
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: csvSelected('local', u.name) ? 'var(--green)' : 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={u.name}>{u.name}</span>
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--dim)' }}>{fmtBytes(u.size)}</span>
                </div>
              ))}
            </div>
          )}
          <div>
            <div style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 3 }}>Uploaded via dashboard ({uploadCsvs.length})</div>
            {uploadCsvs.length === 0 && localFiles.csvs.length === 0
              ? <div style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--dim)', padding: '12px 0' }}>No CSVs available. Upload one or drop a .csv into the bot's ./tasks/ folder.</div>
              : null}
            {uploadCsvs.map((u) => (
              <div key={'u-' + u.name} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 4, background: csvSelected('upload', u.name) ? 'var(--green-soft)' : 'transparent', cursor: 'pointer', marginBottom: 2 }} onClick={() => setCsvPick({ kind: 'upload', name: u.name })}>
                <input type="radio" checked={csvSelected('upload', u.name)} readOnly style={{ accentColor: 'var(--green)' }} />
                <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: csvSelected('upload', u.name) ? 'var(--green)' : 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={u.name}>{u.name}</span>
                <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--dim)' }}>{fmtBytes(u.size)}</span>
                <button onClick={(e) => { e.stopPropagation(); if (window.confirm(`Delete ${u.name}?`)) onDeleteUpload('csv', u.name); }} style={{ background: 'transparent', border: 'none', color: 'var(--red)', cursor: 'pointer', fontSize: 12, padding: '0 4px' }}>✕</button>
              </div>
            ))}
          </div>
        </div>

        {/* Proxylist column */}
        <div>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
            <span style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)', letterSpacing: '0.14em', textTransform: 'uppercase' }}>Proxylist</span>
            <label style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--green)', cursor: 'pointer', letterSpacing: '0.1em' }}>
              + UPLOAD
              <input type="file" accept=".txt,.list" style={{ display: 'none' }} onChange={(e) => upload(e, 'proxylist')} />
            </label>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 4, background: !proxyPick ? 'rgba(111,128,121,0.15)' : 'transparent', cursor: 'pointer', marginBottom: 8 }} onClick={() => setProxyPick(null)}>
            <input type="radio" checked={!proxyPick} readOnly />
            <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }}>(none — direct connection)</span>
          </div>
          {localFiles.proxies.length > 0 && (
            <div style={{ marginBottom: 8 }}>
              <div style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--green)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 3 }}>On bot machine ({localFiles.proxies.length})</div>
              {localFiles.proxies.map((u) => (
                <div key={'l-' + u.name} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 4, background: proxySelected('local', u.name) ? 'var(--green-soft)' : 'transparent', cursor: 'pointer', marginBottom: 2 }} onClick={() => setProxyPick({ kind: 'local', name: u.name })}>
                  <input type="radio" checked={proxySelected('local', u.name)} readOnly style={{ accentColor: 'var(--green)' }} />
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: proxySelected('local', u.name) ? 'var(--green)' : 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={u.name}>{u.name}</span>
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--dim)' }}>{fmtBytes(u.size)}</span>
                </div>
              ))}
            </div>
          )}
          {uploadProxies.length > 0 && (
            <div>
              <div style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 3 }}>Uploaded via dashboard ({uploadProxies.length})</div>
              {uploadProxies.map((u) => (
                <div key={'u-' + u.name} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 4, background: proxySelected('upload', u.name) ? 'var(--green-soft)' : 'transparent', cursor: 'pointer', marginBottom: 2 }} onClick={() => setProxyPick({ kind: 'upload', name: u.name })}>
                  <input type="radio" checked={proxySelected('upload', u.name)} readOnly style={{ accentColor: 'var(--green)' }} />
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: proxySelected('upload', u.name) ? 'var(--green)' : 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={u.name}>{u.name}</span>
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--dim)' }}>{fmtBytes(u.size)}</span>
                  <button onClick={(e) => { e.stopPropagation(); if (window.confirm(`Delete ${u.name}?`)) onDeleteUpload('proxylist', u.name); }} style={{ background: 'transparent', border: 'none', color: 'var(--red)', cursor: 'pointer', fontSize: 12, padding: '0 4px' }}>✕</button>
                </div>
              ))}
            </div>
          )}
        </div>
      </div>

      {err ? <div style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--red)', marginBottom: 8 }}>{err}</div> : null}

      <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
        <button
          className="btn"
          style={{ width: 'auto', margin: 0, padding: '10px 22px' }}
          disabled={busy || !csvPick}
          onClick={start}>
          {busy ? 'STARTING…' : '▶ START'}
        </button>
        <span style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--dim)' }}>
          {csvPick
            ? `Will run ${csvPick.name} (${csvPick.kind})${proxyPick ? ` + ${proxyPick.name} (${proxyPick.kind})` : ' (direct)'}`
            : 'Pick a CSV to enable START'}
        </span>
      </div>
    </div>
  );
}


function SettingsView({ licenseKey, qtConnected, listenerLinks, onRefreshListener }) {
  const [copied, setCopied] = useState(false);
  const copy = () => {
    navigator.clipboard?.writeText(licenseKey);
    setCopied(true);
    setTimeout(() => setCopied(false), 1400);
  };
  return (
    <div className="view">
      <Topbar crumbs={['HopAIO', 'Settings']} right={null} />
      <h1 className="page">Settings</h1>
      <p className="page-sub">Your license, listener, and integrations.</p>
      <div className="card" style={{ padding: '22px 24px', marginBottom: 18 }}>
        <div style={{ fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.2em', color: 'var(--dim)', textTransform: 'uppercase', marginBottom: 10 }}>License key</div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <input readOnly value={licenseKey} style={{ flex: 1, fontFamily: 'var(--mono)', fontSize: 13, color: 'var(--green)', background: 'var(--bg-3)', border: '1px solid var(--line-2)', borderRadius: 6, padding: '12px 14px', letterSpacing: '0.05em' }} />
          <button className="btn" style={{ width: 'auto', margin: 0, padding: '12px 18px', minWidth: 110 }} onClick={copy}>{copied ? '✓ COPIED' : 'COPY'}</button>
        </div>
        <div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', marginTop: 10, letterSpacing: '0.05em' }}>Keep this private. Anyone with this key can sign in to your dashboard.</div>
      </div>
      <div className="card" style={{ padding: '22px 24px' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
          <div>
            <div style={{ fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.2em', color: 'var(--dim)', textTransform: 'uppercase' }}>QT Listener</div>
            <div style={{ fontSize: 14, marginTop: 6 }}>Restock listener bound to <code style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--green)', background: 'var(--green-soft)', padding: '2px 6px', borderRadius: 3 }}>qt.csv</code> on your bot</div>
            <div style={{ fontFamily: 'var(--mono)', fontSize: 11, color: qtConnected ? 'var(--green)' : 'var(--red)', marginTop: 6, letterSpacing: '0.08em' }}>{qtConnected ? `● ONLINE · ${listenerLinks || 0} URLs ACTIVE` : '○ OFFLINE — start the bot on your machine'}</div>
          </div>
          <button className="btn ghost" style={{ width: 'auto', margin: 0, padding: '10px 18px' }} onClick={onRefreshListener}>REFRESH</button>
        </div>
      </div>
    </div>);

}

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