← back to Dw Collections Viewer

public/products.html

324 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><title>All Products — DW</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root{--bg:#0c0a08;--bg2:#15110d;--ink:#f4ecd8;--muted:#a39888;--faint:#5a5246;--rule:#2a241c;--gold:#c9b687;--green:#6b8e6f;--red:#c66c4d;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:300 14px -apple-system,sans-serif;min-height:100vh}
header{padding:22px 28px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;background:var(--bg2);position:sticky;top:0;z-index:5;gap:14px;flex-wrap:wrap}
h1{font:600 12px/1 inherit;letter-spacing:.32em;text-transform:uppercase;margin:0;color:var(--gold)}
nav a{color:var(--muted);font-size:11px;letter-spacing:.16em;text-transform:uppercase;margin-right:18px}
nav a:hover,nav a.active{color:var(--gold)}
.toolbar{padding:14px 28px;background:var(--bg2);border-bottom:1px solid var(--rule);display:flex;gap:12px;align-items:center;flex-wrap:wrap}
input,select,button{background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:8px 14px;font:inherit;font-size:13px;border-radius:2px;outline:none}
button{cursor:pointer;letter-spacing:.06em}
button.primary{background:var(--gold);color:var(--bg);border-color:var(--gold);font-weight:600}
button.danger{border-color:var(--red);color:var(--red)}
button.danger:hover{background:var(--red);color:var(--bg)}
button:disabled{opacity:.4;cursor:not-allowed}
.pill{padding:5px 12px;border:1px solid var(--rule);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;color:var(--muted)}
.pill.live{color:var(--green);border-color:var(--green)}
.pill.warn{color:var(--gold);border-color:var(--gold)}
main{padding:18px 28px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--pcol,180px),1fr));gap:14px}
.card{background:var(--bg2);border:1px solid var(--rule);cursor:pointer;transition:all .15s;position:relative;display:flex;flex-direction:column}
.card:hover{border-color:var(--gold)}
.card.selected{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold) inset}
.card .hero{aspect-ratio:1/1;background:#000;overflow:hidden;position:relative}
.card .hero img{width:100%;height:100%;object-fit:cover;display:block}
.card .check{position:absolute;top:6px;left:6px;width:20px;height:20px;border:1px solid var(--gold);background:rgba(12,10,8,0.7);border-radius:2px;display:flex;align-items:center;justify-content:center;color:var(--gold);font-size:13px;line-height:1}
.card.selected .check::after{content:"✓"}
.card .meta{padding:8px 10px}
.card .title{font-size:11px;line-height:1.3;color:var(--ink);height:2.6em;overflow:hidden}
.card .sub{font-size:10px;color:var(--muted);margin-top:4px;letter-spacing:.05em;display:flex;justify-content:space-between}
.card .price{color:var(--gold)}
.batch-bar{position:fixed;bottom:0;left:0;right:0;padding:14px 28px;background:var(--bg2);border-top:1px solid var(--gold);display:none;gap:12px;align-items:center;z-index:10}
.batch-bar.show{display:flex}
.batch-bar .count{font-size:12px;letter-spacing:.14em;color:var(--gold)}
.empty{padding:80px 0;text-align:center;color:var(--muted);font-size:13px}
.modal{position:fixed;inset:0;background:rgba(12,10,8,0.85);z-index:50;display:none;align-items:center;justify-content:center;padding:30px}
.modal.show{display:flex}
.modal-box{background:var(--bg2);border:1px solid var(--gold);padding:24px;max-width:520px;width:100%}
.modal h3{margin:0 0 14px;font:600 12px/1 inherit;letter-spacing:.24em;text-transform:uppercase;color:var(--gold)}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
.notice{padding:10px 14px;background:rgba(201,108,77,0.08);border:1px solid var(--red);color:var(--red);border-radius:2px;font-size:12px;line-height:1.5;margin-bottom:14px}
.notice.warn{background:rgba(201,182,135,0.08);border-color:var(--gold);color:var(--gold)}
</style></head><body>
<header>
  <div style="display:flex;align-items:center;gap:18px">
    <h1>All Products</h1>
    <nav><a href="/">Sites</a><a class="active" href="/products">Products</a></nav>
  </div>
  <div style="display:flex;gap:8px;align-items:center">
    <span class="pill" id="store-pill">…</span>
    <span class="pill" id="count-pill">… loaded</span>
    <span class="pill" id="admin-pill">…</span>
  </div>
</header>

<div class="toolbar">
  <input id="search" placeholder="filter by title, handle, vendor, type, tag…" style="flex:1;min-width:220px" />
  <select id="status-filter">
    <option value="">all status</option>
    <option value="published">published only</option>
    <option value="draft">draft only</option>
  </select>
  <select id="vendor-filter"><option value="">all vendors</option></select>
  <select id="type-filter"><option value="">all types</option></select>
  <select id="coll-filter"><option value="">all collections</option></select>
  <select id="sort-by" title="Sort products">
    <option value="newest">Newest</option>
    <option value="title">Title A→Z</option>
    <option value="sku">SKU/Handle A→Z</option>
    <option value="vendor">Vendor A→Z</option>
    <option value="price-asc">Price ↑</option>
    <option value="price-desc">Price ↓</option>
  </select>
  <input id="density" type="range" min="120" max="320" step="10" value="180" title="Card density" style="flex:0 0 120px" />
  <button id="select-all">Select all visible</button>
  <button id="clear-sel">Clear</button>
  <button id="reload">↻ Reload</button>
</div>

<main>
  <div id="grid" class="grid"><div class="empty">loading products…</div></div>
</main>

<div class="batch-bar" id="batch-bar">
  <span class="count" id="batch-count">0 selected</span>
  <button id="batch-add" class="primary">Add to collection…</button>
  <button id="batch-remove" id="batch-remove-collection">Remove from current collection</button>
  <button id="batch-delete" class="danger">Delete selected</button>
  <button id="batch-clear">Clear selection</button>
</div>

<!-- Add-to-collection modal -->
<div class="modal" id="add-modal">
  <div class="modal-box">
    <h3>Add <span id="add-count">0</span> products to collection</h3>
    <div id="add-notice" class="notice warn" style="display:none"></div>
    <select id="coll-pick" style="width:100%"><option>Loading collections…</option></select>
    <div class="modal-actions">
      <button id="add-cancel">Cancel</button>
      <button id="add-confirm" class="primary">Add to collection</button>
    </div>
  </div>
</div>

<!-- Delete confirm modal -->
<div class="modal" id="del-modal">
  <div class="modal-box">
    <h3>Delete <span id="del-count">0</span> products</h3>
    <div class="notice">This permanently removes products from the live Shopify store. Cannot be undone.</div>
    <p style="font-size:12px;line-height:1.55">Type <strong style="color:var(--red)">DELETE</strong> to confirm:</p>
    <input id="del-confirm-input" placeholder="DELETE" autocomplete="off" />
    <div class="modal-actions">
      <button id="del-cancel">Cancel</button>
      <button id="del-confirm" class="danger" disabled>Delete permanently</button>
    </div>
  </div>
</div>

<script>
let PRODUCTS = [], FILTERED = [], SELECTED = new Set(), COLLECTIONS = [], COLL_FILTER_IDS = null;
const $ = id => document.getElementById(id);

// Sort + density state — persisted in localStorage so the choice survives reloads.
let PRODUCTS_SORT = localStorage.getItem('dwcv_prod_sort') || 'newest';
let PRODUCTS_SIZE = parseInt(localStorage.getItem('dwcv_prod_size') || '180', 10);

function sortProducts(list) {
  const arr = list.slice();
  const num = v => { const n = parseFloat(v); return isNaN(n) ? 0 : n; };
  const txt = v => String(v == null ? '' : v).toLowerCase();
  switch (PRODUCTS_SORT) {
    case 'title':      arr.sort((a, b) => txt(a.title).localeCompare(txt(b.title))); break;
    case 'sku':        arr.sort((a, b) => txt(a.sku || a.handle).localeCompare(txt(b.sku || b.handle))); break;
    case 'vendor':     arr.sort((a, b) => txt(a.vendor).localeCompare(txt(b.vendor))); break;
    case 'price-asc':  arr.sort((a, b) => num(a.price) - num(b.price)); break;
    case 'price-desc': arr.sort((a, b) => num(b.price) - num(a.price)); break;
    case 'newest':     default: break; // server's natural order
  }
  return arr;
}

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

async function load() {
  $('grid').innerHTML = '<div class="empty">loading products…</div>';
  try {
    const r = await fetch('/api/products/all').then(r => r.json());
    PRODUCTS = r.products || [];
    $('store-pill').textContent = r.store;
    $('count-pill').textContent = `${PRODUCTS.length} products`;
    $('count-pill').classList.add('live');
    populateFilters();
    render();
    // Probe admin token availability via collections endpoint
    fetch('/api/admin/collections').then(async x => {
      if (x.ok) {
        const c = await x.json(); COLLECTIONS = c.collections || [];
        $('admin-pill').textContent = `admin: ${c.custom||0} custom + ${c.smart||0} smart`; $('admin-pill').classList.add('live');
        // Group filter dropdown by type. Custom listed first (writable). Smart marked "auto" (read-only filter).
        const custom = COLLECTIONS.filter(c => c.type === 'custom');
        const smart  = COLLECTIONS.filter(c => c.type === 'smart');
        let html = '<option value="">all collections</option>';
        if (custom.length) html += '<optgroup label="Custom (manual)">' + custom.map(c => `<option value="${c.id}" data-type="custom">${esc(c.title)} (${c.products_count || 0})</option>`).join('') + '</optgroup>';
        if (smart.length)  html += '<optgroup label="Smart (auto-rules)">' + smart.map(c => `<option value="${c.id}" data-type="smart">${esc(c.title)} (${c.products_count || 0}) ⚙</option>`).join('') + '</optgroup>';
        $('coll-filter').innerHTML = html;
      }
      else { $('admin-pill').textContent = 'admin token missing'; $('admin-pill').classList.add('warn'); }
    }).catch(() => { $('admin-pill').textContent = 'admin: offline'; $('admin-pill').classList.add('warn'); });
  } catch (e) {
    $('grid').innerHTML = `<div class="empty">load failed: ${esc(e.message)}</div>`;
  }
}

function populateFilters() {
  const vendors = [...new Set(PRODUCTS.map(p => p.vendor).filter(Boolean))].sort();
  const types = [...new Set(PRODUCTS.map(p => p.product_type).filter(Boolean))].sort();
  $('vendor-filter').innerHTML = '<option value="">all vendors</option>' + vendors.map(v => `<option>${esc(v)}</option>`).join('');
  $('type-filter').innerHTML = '<option value="">all types</option>' + types.map(t => `<option>${esc(t)}</option>`).join('');
}

function render() {
  const q = $('search').value.toLowerCase();
  const stat = $('status-filter').value;
  const vend = $('vendor-filter').value;
  const typ = $('type-filter').value;
  FILTERED = PRODUCTS.filter(p => {
    if (stat && p.status !== stat) return false;
    if (vend && p.vendor !== vend) return false;
    if (typ && p.product_type !== typ) return false;
    if (COLL_FILTER_IDS && !COLL_FILTER_IDS.has(p.id)) return false;
    if (q) {
      const hay = `${p.title} ${p.handle} ${p.vendor} ${p.product_type} ${p.tags}`.toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  });
  $('grid').innerHTML = FILTERED.length ? FILTERED.map(p => `
    <div class="card ${SELECTED.has(p.id)?'selected':''}" data-id="${p.id}">
      <div class="check"></div>
      <div class="hero">${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy">` : ''}</div>
      <div class="meta">
        <div class="title">${esc(p.title)}</div>
        <div class="sub"><span>${esc(p.vendor || '')}</span><span class="price">$${esc(p.price || '—')}</span></div>
      </div>
    </div>
  `).join('') : '<div class="empty">no products match filters</div>';
  $('grid').querySelectorAll('.card').forEach(c => c.addEventListener('click', () => {
    const id = parseInt(c.dataset.id, 10);
    if (SELECTED.has(id)) SELECTED.delete(id); else SELECTED.add(id);
    c.classList.toggle('selected');
    updateBatch();
  }));
  updateBatch();
}

function updateBatch() {
  const bar = $('batch-bar');
  if (SELECTED.size) { bar.classList.add('show'); $('batch-count').textContent = `${SELECTED.size} selected`; }
  else bar.classList.remove('show');
}

['search','status-filter','vendor-filter','type-filter'].forEach(id => $(id).addEventListener('input', render));
$('coll-filter').addEventListener('change', async e => {
  const cid = e.target.value;
  if (!cid) { COLL_FILTER_IDS = null; render(); return; }
  $('grid').innerHTML = '<div class="empty">loading collection products…</div>';
  try {
    const r = await fetch(`/api/admin/collection/${cid}/products`).then(r => r.json());
    if (r.error) throw new Error(r.error);
    COLL_FILTER_IDS = new Set(r.ids);
    render();
  } catch (e) { COLL_FILTER_IDS = null; alert('failed: ' + e.message); render(); }
});
$('reload').addEventListener('click', load);
$('select-all').addEventListener('click', () => { FILTERED.forEach(p => SELECTED.add(p.id)); render(); });
$('clear-sel').addEventListener('click', () => { SELECTED.clear(); render(); });
$('batch-clear').addEventListener('click', () => { SELECTED.clear(); render(); });

// Add-to-collection flow
$('batch-add').addEventListener('click', () => {
  if (!SELECTED.size) return;
  $('add-count').textContent = SELECTED.size;
  // Add-to: only custom collections are writable. Smart collections auto-include via rules.
  const writable = COLLECTIONS.filter(c => c.type === 'custom');
  if (!writable.length) {
    $('add-notice').style.display = 'block';
    $('add-notice').textContent = 'No custom (writable) collections found. Smart collections are auto-rule-based — products land in them automatically based on tags/title.';
    $('coll-pick').innerHTML = '<option>(no writable collections)</option>';
    $('add-confirm').disabled = true;
  } else {
    $('add-notice').style.display = 'none';
    $('coll-pick').innerHTML = writable.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
    $('add-confirm').disabled = false;
  }
  $('add-modal').classList.add('show');
});
$('add-cancel').addEventListener('click', () => $('add-modal').classList.remove('show'));
$('batch-remove').addEventListener('click', async () => {
  if (!SELECTED.size) return;
  const sel = $('coll-filter');
  const cid = sel.value;
  if (!cid) return alert('Pick a collection in the filter dropdown first — the "Remove" action removes from that collection.');
  // Smart collections aren't manually editable; rules drive membership.
  if (sel.options[sel.selectedIndex]?.dataset.type === 'smart') {
    return alert("Smart collections are auto-rule-based — Shopify won't let us remove products manually. Edit the collection's tag/title rules to change membership.");
  }
  if (!confirm(`Remove ${SELECTED.size} products from this collection? Products themselves are not deleted.`)) return;
  try {
    const r = await fetch('/api/products/remove-from-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: parseInt(cid,10) }) });
    const j = await r.json();
    alert(`${j.ok}/${j.count} removed from collection`);
    SELECTED.clear();
    // Refresh collection filter
    $('coll-filter').dispatchEvent(new Event('change'));
  } catch (e) { alert('failed: ' + e.message); }
});

$('add-confirm').addEventListener('click', async () => {
  const cid = parseInt($('coll-pick').value, 10);
  if (!cid || !SELECTED.size) return;
  $('add-confirm').disabled = true; $('add-confirm').textContent = 'Adding…';
  try {
    const r = await fetch('/api/products/add-to-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: cid }) });
    const j = await r.json();
    alert(`${j.ok}/${j.count} products added`);
    $('add-modal').classList.remove('show');
    SELECTED.clear(); render();
  } catch (e) { alert('failed: ' + e.message); }
  $('add-confirm').disabled = false; $('add-confirm').textContent = 'Add to collection';
});

// Delete flow
$('batch-delete').addEventListener('click', () => {
  if (!SELECTED.size) return;
  $('del-count').textContent = SELECTED.size;
  $('del-confirm-input').value = '';
  $('del-confirm').disabled = true;
  $('del-modal').classList.add('show');
});
$('del-confirm-input').addEventListener('input', e => {
  $('del-confirm').disabled = e.target.value !== 'DELETE';
});
$('del-cancel').addEventListener('click', () => $('del-modal').classList.remove('show'));
$('del-confirm').addEventListener('click', async () => {
  $('del-confirm').disabled = true; $('del-confirm').textContent = 'Deleting…';
  try {
    const r = await fetch('/api/products/delete', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ ids:[...SELECTED] }) });
    const j = await r.json();
    alert(`${j.ok}/${j.count} products deleted`);
    $('del-modal').classList.remove('show');
    SELECTED.clear();
    load(); // refetch
  } catch (e) { alert('failed: ' + e.message); }
  $('del-confirm').disabled = false; $('del-confirm').textContent = 'Delete permanently';
});

load();
</script>
</body></html>