← back to Quadrille Showroom

proto/v8-swipe.html

603 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Swipe Tower — Curated Wallcovering Discovery</title>
<style>
  /* ───────────────────────────  TOKENS  ─────────────────────────── */
  :root{
    --bg:        #f4f1ec;   /* warm gallery linen   */
    --bg-deep:   #ebe6dd;
    --ink:       #1c1a17;   /* near-black warm ink  */
    --ink-soft:  #6b655c;
    --line:      #d8d1c4;
    --card:      #ffffff;
    --keep:      #2f6b4f;   /* deep botanical green */
    --keep-tint: #e7f0ea;
    --skip:      #8a8178;   /* muted stone          */
    --skip-tint: #efece6;
    --gold:      #b08a4f;
    --shadow:    0 24px 60px -24px rgba(28,26,23,.42), 0 4px 14px -6px rgba(28,26,23,.22);
    --serif: "Hoefler Text", "Iowan Old Style", "Palatino Linotype", Georgia, "Times New Roman", serif;
    --sans:  -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  }
  *{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
  html,body{ height:100%; margin:0; }
  body{
    background:
      radial-gradient(120% 80% at 50% -10%, var(--bg) 0%, var(--bg-deep) 70%, #e3ddd2 100%);
    color:var(--ink);
    font-family:var(--sans);
    overflow:hidden;
    -webkit-font-smoothing:antialiased;
  }

  /* ───────────────────────────  LAYOUT  ─────────────────────────── */
  .app{
    height:100dvh;
    display:grid;
    grid-template-columns: 1fr 300px;
    grid-template-rows: auto 1fr;
    grid-template-areas:
      "head head"
      "stage tray";
    max-width:1240px; margin:0 auto;
  }
  @media (max-width: 860px){
    .app{ grid-template-columns:1fr; grid-template-rows:auto 1fr auto; grid-template-areas:"head" "stage" "tray"; }
  }

  /* ───────────────────────────  HEADER  ─────────────────────────── */
  header{
    grid-area:head;
    padding:22px 30px 14px;
    display:flex; align-items:baseline; justify-content:space-between; gap:18px;
    border-bottom:1px solid var(--line);
  }
  .brand{ display:flex; flex-direction:column; gap:3px; }
  .brand h1{
    font-family:var(--serif); font-weight:500; font-size:26px; letter-spacing:.5px;
    margin:0; line-height:1;
  }
  .brand .sub{
    font-size:11px; letter-spacing:.32em; text-transform:uppercase; color:var(--ink-soft);
  }
  .counter{ text-align:right; font-size:13px; color:var(--ink-soft); line-height:1.5; }
  .counter b{ color:var(--ink); font-variant-numeric:tabular-nums; }

  /* ───────────────────────────  STAGE  ─────────────────────────── */
  .stage{
    grid-area:stage;
    position:relative;
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    padding:24px 20px 18px;
    min-height:0;
  }
  .deck{
    position:relative;
    width:min(78vmin, 460px);
    aspect-ratio:1/1;
    flex:0 0 auto;
  }

  /* card */
  .card{
    position:absolute; inset:0;
    background:var(--card);
    border-radius:14px;
    box-shadow:var(--shadow);
    overflow:hidden;
    display:flex; flex-direction:column;
    transform-origin:center bottom;
    will-change:transform, opacity;
    user-select:none;
  }
  .card.behind-1{ transform:translateY(14px) scale(.955); }
  .card.behind-2{ transform:translateY(26px) scale(.91); }
  .card .swatch{
    position:relative;
    flex:1 1 auto;
    background:var(--bg-deep);
    background-size:cover; background-position:center;
    min-height:0;
  }
  .card .swatch::after{ /* hairline frame inside swatch */
    content:""; position:absolute; inset:0;
    box-shadow: inset 0 0 0 1px rgba(28,26,23,.06);
    pointer-events:none;
  }
  .card .meta{
    flex:0 0 auto;
    padding:14px 18px 16px;
    border-top:1px solid var(--line);
    background:linear-gradient(180deg,#fff, #fbf9f5);
  }
  .card .pname{
    font-family:var(--serif); font-size:21px; line-height:1.15; font-weight:500;
    margin:0 0 4px; color:var(--ink);
    display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
  }
  .card .pvendor{ font-size:12px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin-bottom:8px; }
  .card .spec{ display:flex; gap:14px; font-size:12.5px; color:var(--ink-soft); flex-wrap:wrap; }
  .card .spec span{ display:inline-flex; align-items:center; gap:5px; }
  .card .spec .dot{ width:5px; height:5px; border-radius:50%; background:var(--line); }

  /* swipe verdict stamps */
  .stamp{
    position:absolute; top:26px; padding:8px 18px;
    font-family:var(--serif); font-size:24px; letter-spacing:.06em;
    border:2.5px solid currentColor; border-radius:8px;
    opacity:0; transition:opacity .08s; pointer-events:none;
    background:rgba(255,255,255,.78); backdrop-filter:blur(2px);
  }
  .stamp.keep{ left:24px; color:var(--keep); transform:rotate(-11deg); }
  .stamp.skip{ right:24px; color:var(--skip); transform:rotate(11deg); }

  /* empty state */
  .empty{
    position:absolute; inset:0; display:none; flex-direction:column;
    align-items:center; justify-content:center; text-align:center; gap:14px; padding:30px;
  }
  .empty.show{ display:flex; }
  .empty h2{ font-family:var(--serif); font-weight:500; font-size:24px; margin:0; }
  .empty p{ color:var(--ink-soft); font-size:14px; margin:0; max-width:32ch; line-height:1.5; }
  .empty button{
    margin-top:8px; font:inherit; font-size:14px; letter-spacing:.04em;
    border:1px solid var(--ink); background:none; color:var(--ink);
    padding:11px 24px; border-radius:30px; cursor:pointer;
  }
  .empty button:hover{ background:var(--ink); color:#fff; }

  /* ───────────────────────────  ACTION BAR  ─────────────────────────── */
  .actions{
    margin-top:22px; display:flex; align-items:center; gap:26px;
  }
  .act{
    --c:var(--ink);
    width:80px; height:80px; border-radius:50%;
    border:2px solid var(--line); background:#fff;
    display:flex; align-items:center; justify-content:center;
    cursor:pointer; transition:transform .12s ease, box-shadow .2s, border-color .2s, background .2s;
    box-shadow:0 6px 18px -8px rgba(28,26,23,.3);
    position:relative;
  }
  .act svg{ width:34px; height:34px; }
  .act:hover{ transform:translateY(-3px); }
  .act:active{ transform:scale(.92); }
  .act .lbl{
    position:absolute; bottom:-24px; left:50%; transform:translateX(-50%);
    font-size:10.5px; letter-spacing:.22em; text-transform:uppercase; color:var(--ink-soft);
    white-space:nowrap;
  }
  .act.skip{ color:var(--skip); }
  .act.skip:hover{ border-color:var(--skip); background:var(--skip-tint); }
  .act.keep{ color:var(--keep); width:96px; height:96px; }
  .act.keep svg{ width:42px; height:42px; }
  .act.keep:hover{ border-color:var(--keep); background:var(--keep-tint); }
  .act.undo{ width:58px; height:58px; color:var(--ink-soft); }
  .act.undo svg{ width:24px; height:24px; }
  .act.undo:disabled{ opacity:.32; cursor:default; transform:none; }
  .hint{ margin-top:34px; font-size:11.5px; letter-spacing:.04em; color:var(--ink-soft); text-align:center; }
  .hint kbd{
    font-family:var(--sans); font-size:11px; background:#fff; border:1px solid var(--line);
    border-bottom-width:2px; border-radius:5px; padding:1px 7px; color:var(--ink);
  }

  /* ───────────────────────────  FAVORITES TRAY  ─────────────────────────── */
  .tray{
    grid-area:tray;
    border-left:1px solid var(--line);
    background:linear-gradient(180deg, #faf8f3, #f3efe7);
    display:flex; flex-direction:column; min-height:0;
  }
  @media (max-width:860px){ .tray{ border-left:none; border-top:1px solid var(--line); max-height:38vh; } }
  .tray-head{
    padding:18px 22px 12px; display:flex; align-items:baseline; justify-content:space-between;
    border-bottom:1px solid var(--line);
  }
  .tray-head h2{ font-family:var(--serif); font-weight:500; font-size:17px; margin:0; letter-spacing:.02em; }
  .tray-head .ct{
    font-size:12px; color:#fff; background:var(--keep);
    min-width:24px; height:24px; padding:0 8px; border-radius:12px;
    display:inline-flex; align-items:center; justify-content:center; font-variant-numeric:tabular-nums;
  }
  .tray-head .ct.zero{ background:var(--line); color:var(--ink-soft); }
  .tray-list{ flex:1 1 auto; overflow-y:auto; padding:12px 14px 18px; display:flex; flex-direction:column; gap:10px; }
  .tray-empty{ color:var(--ink-soft); font-size:13px; line-height:1.6; padding:18px 8px; text-align:center; }
  .tray-empty .heart{ font-size:26px; display:block; margin-bottom:8px; opacity:.4; }

  .fav{
    display:flex; gap:11px; align-items:center;
    background:#fff; border:1px solid var(--line); border-radius:10px;
    padding:8px; position:relative;
    animation:favIn .42s cubic-bezier(.2,.9,.25,1) both;
  }
  @keyframes favIn{ from{ opacity:0; transform:translateX(16px) scale(.96);} to{opacity:1; transform:none;} }
  .fav .thumb{ width:54px; height:54px; border-radius:7px; background:var(--bg-deep) center/cover; flex:0 0 auto; box-shadow:inset 0 0 0 1px rgba(0,0,0,.05); }
  .fav .fmeta{ min-width:0; flex:1 1 auto; }
  .fav .fname{ font-family:var(--serif); font-size:14px; line-height:1.2; margin:0 0 2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .fav .fvendor{ font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--gold); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .fav .fspec{ font-size:10.5px; color:var(--ink-soft); margin-top:2px; }
  .fav .rm{
    position:absolute; top:6px; right:6px; width:20px; height:20px; border-radius:50%;
    border:none; background:var(--skip-tint); color:var(--skip); cursor:pointer;
    font-size:13px; line-height:1; display:flex; align-items:center; justify-content:center;
    opacity:0; transition:opacity .15s;
  }
  .fav:hover .rm{ opacity:1; }
  .tray-foot{ flex:0 0 auto; padding:14px 18px; border-top:1px solid var(--line); }
  .tray-foot button{
    width:100%; font:inherit; font-size:13.5px; letter-spacing:.1em; text-transform:uppercase;
    background:var(--ink); color:#fff; border:none; border-radius:30px; padding:13px; cursor:pointer;
    transition:opacity .2s; display:flex; align-items:center; justify-content:center; gap:8px;
  }
  .tray-foot button:disabled{ opacity:.3; cursor:default; }
  .tray-foot button:not(:disabled):hover{ background:var(--keep); }
  .tray-foot .note{ font-size:11px; color:var(--ink-soft); text-align:center; margin:9px 0 0; line-height:1.4; }

  .loading{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--ink-soft); font-size:14px; letter-spacing:.1em; }
  @media (prefers-reduced-motion: reduce){ *{ animation-duration:.001s !important; transition-duration:.001s !important; } }
</style>
</head>
<body>
<div class="app">
  <header>
    <div class="brand">
      <h1>Swipe Tower</h1>
      <div class="sub">Curated Wallcovering Discovery</div>
    </div>
    <div class="counter">
      <div>Reviewed <b id="seenCt">0</b></div>
      <div><b id="leftCt">0</b> remaining</div>
    </div>
  </header>

  <section class="stage">
    <div class="deck" id="deck">
      <div class="loading" id="loading">Curating your collection…</div>
      <div class="empty" id="empty">
        <h2 id="emptyTitle">That's the collection</h2>
        <p id="emptyMsg">You've seen every swatch in this curation. Your favorites are saved in the tray.</p>
        <button id="restartBtn">Start over</button>
      </div>
    </div>

    <div class="actions" id="actions" style="visibility:hidden">
      <button class="act undo" id="undoBtn" title="Undo last (Backspace)" disabled aria-label="Undo last">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M3 13a9 9 0 1 0 3-7.7L3 8"/></svg>
      </button>
      <button class="act skip" id="skipBtn" title="Skip (← arrow)" aria-label="Skip this swatch">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        <span class="lbl">Skip</span>
      </button>
      <button class="act keep" id="keepBtn" title="Keep (→ arrow)" aria-label="Keep this swatch">
        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-7.5-4.7-10-9.2C.3 8.4 1.9 4.6 5.4 4.1 7.6 3.8 9.5 5 12 7.6 14.5 5 16.4 3.8 18.6 4.1c3.5.5 5.1 4.3 3.4 7.7C19.5 16.3 12 21 12 21z"/></svg>
        <span class="lbl">Keep</span>
      </button>
    </div>
    <div class="hint" id="hint" style="visibility:hidden">
      Drag a swatch, tap a button, or use <kbd>←</kbd> skip &nbsp; <kbd>→</kbd> keep &nbsp; <kbd>↩</kbd> undo
    </div>
  </section>

  <aside class="tray">
    <div class="tray-head">
      <h2>Your Favorites</h2>
      <span class="ct zero" id="favCt">0</span>
    </div>
    <div class="tray-list" id="favList">
      <div class="tray-empty" id="trayEmpty">
        <span class="heart">♥</span>
        Swatches you keep collect here.<br>Build a palette, then request samples.
      </div>
    </div>
    <div class="tray-foot">
      <button id="sampleBtn" disabled>Request <span id="sampleCt">0</span> Samples</button>
      <p class="note" id="sampleNote">No order minimum on memo samples.</p>
    </div>
  </aside>
</div>

<script>
const API = "http://127.0.0.1:7690/api/showroom/products?limit=120";
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#ebe6dd"/></svg>');

/* Offline fallback so the mechanic is demonstrable even when the showroom API
   is unreachable (e.g. opened as a bare file:// with CORS blocking the fetch).
   Generated swatch tiles via inline SVG so no external image dependency. */
function swatchSVG(bg, fg, motif){
  const m = {
    trellis:`<path d="M0 50 L50 0 L100 50 L50 100 Z" fill="none" stroke="${fg}" stroke-width="3"/><path d="M50 0 L100 50 L50 100 L0 50 Z" fill="none" stroke="${fg}" stroke-width="3"/><circle cx="50" cy="50" r="6" fill="${fg}"/>`,
    leaf:`<path d="M50 8 C30 30 30 70 50 92 C70 70 70 30 50 8 Z" fill="${fg}" opacity=".85"/><path d="M50 8 L50 92" stroke="${bg}" stroke-width="2"/>`,
    floral:`<g fill="${fg}"><circle cx="50" cy="32" r="11"/><circle cx="32" cy="55" r="11"/><circle cx="68" cy="55" r="11"/><circle cx="50" cy="72" r="11"/><circle cx="50" cy="52" r="7" fill="${bg}"/></g>`,
    stripe:`<rect x="14" width="14" height="100" fill="${fg}" opacity=".8"/><rect x="58" width="14" height="100" fill="${fg}" opacity=".8"/>`,
    geo:`<rect x="20" y="20" width="60" height="60" fill="none" stroke="${fg}" stroke-width="4"/><rect x="35" y="35" width="30" height="30" fill="${fg}" opacity=".7"/>`
  }[motif] || '';
  const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100"><rect width="100" height="100" fill="${bg}"/>${m}</svg>`;
  return "data:image/svg+xml;utf8,"+encodeURIComponent(svg);
}
const FALLBACK = [
  ["Aga Jungle on Off-White","China Seas","#f3efe6","#2f6b4f","leaf","Jungle Green"],
  ["Lyford Trellis — Indigo","Quadrille","#1f3a5f","#e8edf4","trellis","Indigo"],
  ["Fiorentina — Coral","China Seas","#fbeee6","#c4674e","floral","Coral"],
  ["Henriot Stripe — Oatmeal","Quadrille","#efe9dc","#8a7a5c","stripe","Oatmeal"],
  ["Veneto — Charcoal","China Seas","#2b2926","#d8cfbf","geo","Charcoal"],
  ["Arbre de Matisse — Celadon","Quadrille","#e7efe4","#5e8a6a","leaf","Celadon"],
  ["Bali Hai — Aqua","China Seas","#e2f1f0","#3a8f93","floral","Aqua"],
  ["Cap Ferrat — Rose","Quadrille","#f7e9ec","#b06a82","trellis","Rose"],
  ["Sigourney — Mustard","China Seas","#f4ead0","#c79a3a","stripe","Mustard"],
  ["Persia — Ink","Quadrille","#1c1f2b","#cfc6b4","geo","Ink"],
  ["Lulu — Blush","China Seas","#fbeef0","#d398a6","floral","Blush"],
  ["Kazak — Terracotta","Quadrille","#f0e4dc","#b15a3c","trellis","Terracotta"],
].map((r,i)=>({
  pattern_name:r[0], vendor:r[1], collection:r[1],
  image:swatchSVG(r[2],r[3],r[4]), color_name:r[5],
  width:27, repeat:27, sku:"DEMO-"+(100+i)
}));

let queue = [];      // remaining products (head = top card)
let history = [];     // {item, verdict} for undo
let favorites = [];   // kept items
let seen = 0;

const $ = id => document.getElementById(id);
const deck = $("deck");

/* ── derive a short color/colorway word from pattern_name (hue is null in data) ── */
const COLORWORDS = ["white","off-white","ivory","cream","oatmeal","alabaster","greige","grey","gray","charcoal","black","ink","navy","blue","indigo","teal","aqua","celadon","sage","green","jungle","olive","moss","gold","ochre","mustard","yellow","apricot","coral","blush","pink","rose","red","crimson","terracotta","rust","brown","taupe","tan","sand","bronze","copper","silver","plum","purple","lavender","aubergine"];
function colorwordFor(p){
  if(p.color_name) return p.color_name;
  const name = (p.pattern_name||"").toLowerCase();
  const hit = COLORWORDS.find(c => name.includes(c));
  if(hit) return hit.replace(/\b\w/g, m=>m.toUpperCase());
  if(p.color) return p.color;
  return null;
}
function specLine(p){
  const w = p.width || 27, r = p.repeat || 27;
  const parts = [`${w}″ wide`];
  if(r) parts.push(`${r}″ repeat`);
  return parts;
}

/* ───────────────────────────  CARD RENDER  ─────────────────────────── */
function makeCard(p, depth){
  const card = document.createElement("div");
  card.className = "card" + (depth===1?" behind-1":depth===2?" behind-2":"");
  card.dataset.depth = depth;

  const swatch = document.createElement("div");
  swatch.className = "swatch";
  const img = new Image();
  img.onload = ()=>{ swatch.style.backgroundImage = `url("${p.image}")`; };
  img.onerror = ()=>{ swatch.style.backgroundImage = `url("${PLACEHOLDER}")`; };
  img.src = p.image || PLACEHOLDER;

  const stampKeep = el("div","stamp keep","Keep");
  const stampSkip = el("div","stamp skip","Skip");
  swatch.append(stampKeep, stampSkip);

  const meta = document.createElement("div");
  meta.className = "meta";
  const cw = colorwordFor(p);
  meta.innerHTML = `
    <p class="pname">${esc(cleanName(p.pattern_name))}</p>
    <div class="pvendor">${esc(p.vendor || p.collection || "Designer Wallcoverings")}</div>
    <div class="spec">
      ${specLine(p).map(s=>`<span>${esc(s)}</span>`).join('<span class="dot"></span>')}
      ${cw?`<span class="dot"></span><span>${esc(cw)}</span>`:""}
    </div>`;

  card.append(swatch, meta);
  return card;
}
function el(t,c,txt){ const e=document.createElement(t); e.className=c; e.textContent=txt; return e; }
function esc(s){ return String(s==null?"":s).replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
function cleanName(s){ return (s||"Untitled").replace(/\s*-\s*[^-]+$/, m => m.length<22 ? m : ""); }

/* ───────────────────────────  DECK STATE  ─────────────────────────── */
function renderDeck(){
  // remove existing cards (keep loading/empty nodes)
  deck.querySelectorAll(".card").forEach(c=>c.remove());
  const top3 = queue.slice(0,3);
  // render back-to-front so top card is last (highest z)
  for(let i=top3.length-1; i>=0; i--){
    const card = makeCard(top3[i], i);
    deck.appendChild(card);
    if(i===0) attachDrag(card);
  }
  updateCounters();
  const done = queue.length===0;
  $("empty").classList.toggle("show", done);
  $("actions").style.visibility = done ? "hidden":"visible";
  $("hint").style.visibility = done ? "hidden":"visible";
  $("undoBtn").disabled = history.length===0;
  $("emptyTitle").textContent = favorites.length ? "That's the collection" : "All caught up";
}

function updateCounters(){
  $("seenCt").textContent = seen;
  $("leftCt").textContent = queue.length;
  const n = favorites.length;
  const fc = $("favCt"); fc.textContent = n; fc.classList.toggle("zero", n===0);
  $("sampleCt").textContent = n;
  $("sampleBtn").disabled = n===0;
}

/* ───────────────────────────  VERDICT / THROW  ─────────────────────────── */
let busy = false;
function topCard(){ return deck.querySelector('.card[data-depth="0"]'); }

function decide(verdict, fromDrag){
  if(busy || queue.length===0) return;
  busy = true;
  const card = topCard();
  const item = queue[0];
  const dir = verdict==="keep" ? 1 : -1;

  // show stamp
  const stamp = card.querySelector(verdict==="keep" ? ".stamp.keep" : ".stamp.skip");
  if(stamp) stamp.style.opacity = "1";

  // throw animation
  const throwX = dir * (window.innerWidth*0.9 + 200);
  const rot = dir * 18;
  card.style.transition = "transform .42s cubic-bezier(.36,.07,.4,1), opacity .42s";
  card.style.transform = `translate(${throwX}px, -40px) rotate(${rot}deg)`;
  card.style.opacity = "0";

  // promote remaining cards immediately so the stack feels live
  const b1 = deck.querySelector('.card[data-depth="1"]');
  const b2 = deck.querySelector('.card[data-depth="2"]');
  if(b1){ b1.style.transition="transform .42s cubic-bezier(.2,.9,.25,1)"; b1.className="card"; b1.dataset.depth="0"; }
  if(b2){ b2.style.transition="transform .42s cubic-bezier(.2,.9,.25,1)"; b2.className="card behind-1"; b2.dataset.depth="1"; }

  // commit state
  queue.shift();
  seen++;
  history.push({item, verdict});
  if(verdict==="keep"){ favorites.unshift(item); addFav(item); }

  setTimeout(()=>{
    busy = false;
    renderDeck(); // rebuilds stack cleanly + brings in next back card + wires drag
  }, 430);
}

function undo(){
  if(busy || history.length===0) return;
  const last = history.pop();
  queue.unshift(last.item);
  seen = Math.max(0, seen-1);
  if(last.verdict==="keep"){
    favorites = favorites.filter(f=>f!==last.item);
    removeFavNode(last.item);
  }
  renderDeck();
}

/* ───────────────────────────  FAVORITES TRAY  ─────────────────────────── */
const favList = $("favList");
function addFav(p){
  $("trayEmpty").style.display = "none";
  const row = document.createElement("div");
  row.className = "fav";
  row._item = p;
  const cw = colorwordFor(p);
  row.innerHTML = `
    <div class="thumb" style="background-image:url('${(p.image||PLACEHOLDER).replace(/'/g,"%27")}')"></div>
    <div class="fmeta">
      <p class="fname">${esc(cleanName(p.pattern_name))}</p>
      <div class="fvendor">${esc(p.vendor||p.collection||"")}</div>
      <div class="fspec">${(p.width||27)}″ &middot; ${cw?esc(cw):"swatch"}</div>
    </div>
    <button class="rm" title="Remove" aria-label="Remove">&times;</button>`;
  row.querySelector(".rm").onclick = ()=> removeFav(p);
  favList.prepend(row);
  updateCounters();
}
function removeFav(p){
  favorites = favorites.filter(f=>f!==p);
  removeFavNode(p);
  updateCounters();
}
function removeFavNode(p){
  [...favList.querySelectorAll(".fav")].forEach(r=>{ if(r._item===p) r.remove(); });
  if(favorites.length===0) $("trayEmpty").style.display = "";
}

/* ───────────────────────────  DRAG TO SWIPE  ─────────────────────────── */
function attachDrag(card){
  let startX=0, startY=0, dx=0, dy=0, dragging=false;
  const threshold = 110;

  const onDown = e=>{
    if(busy) return;
    dragging = true;
    const pt = e.touches?e.touches[0]:e;
    startX = pt.clientX; startY = pt.clientY; dx=dy=0;
    card.style.transition = "none";
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    window.addEventListener("touchmove", onMove, {passive:false});
    window.addEventListener("touchend", onUp);
  };
  const onMove = e=>{
    if(!dragging) return;
    const pt = e.touches?e.touches[0]:e;
    dx = pt.clientX-startX; dy = pt.clientY-startY;
    if(e.cancelable && Math.abs(dx)>6) e.preventDefault();
    const rot = dx/22;
    card.style.transform = `translate(${dx}px, ${dy*0.4}px) rotate(${rot}deg)`;
    const ks = card.querySelector(".stamp.keep"), ss = card.querySelector(".stamp.skip");
    if(ks) ks.style.opacity = dx>20 ? Math.min(1, dx/threshold).toFixed(2):0;
    if(ss) ss.style.opacity = dx<-20 ? Math.min(1, -dx/threshold).toFixed(2):0;
  };
  const onUp = ()=>{
    if(!dragging) return;
    dragging = false;
    window.removeEventListener("mousemove", onMove);
    window.removeEventListener("mouseup", onUp);
    window.removeEventListener("touchmove", onMove);
    window.removeEventListener("touchend", onUp);
    if(Math.abs(dx) > threshold){
      decide(dx>0 ? "keep":"skip", true);
    } else {
      card.style.transition = "transform .3s cubic-bezier(.2,.9,.25,1)";
      card.style.transform = "";
      const ks=card.querySelector(".stamp.keep"), ss=card.querySelector(".stamp.skip");
      if(ks) ks.style.opacity=0; if(ss) ss.style.opacity=0;
    }
  };
  card.addEventListener("mousedown", onDown);
  card.addEventListener("touchstart", onDown, {passive:true});
}

/* ───────────────────────────  WIRING  ─────────────────────────── */
$("keepBtn").onclick = ()=> decide("keep");
$("skipBtn").onclick = ()=> decide("skip");
$("undoBtn").onclick = undo;
$("restartBtn").onclick = ()=> { history=[]; favorites.forEach(removeFavNode); favorites=[]; load(true); };
$("sampleBtn").onclick = ()=>{
  if(!favorites.length) return;
  const note = $("sampleNote");
  note.textContent = `${favorites.length} sample${favorites.length>1?"s":""} queued — a stylist will confirm your memo set.`;
  note.style.color = "var(--keep)";
};

document.addEventListener("keydown", e=>{
  if(e.key==="ArrowRight"){ e.preventDefault(); decide("keep"); }
  else if(e.key==="ArrowLeft"){ e.preventDefault(); decide("skip"); }
  else if(e.key==="Backspace"){ e.preventDefault(); undo(); }
});

/* ───────────────────────────  LOAD  ─────────────────────────── */
async function load(reshuffle){
  $("loading").style.display = "flex";
  $("empty").classList.remove("show");
  let items = [];
  try{
    const r = await fetch(API);
    if(!r.ok) throw new Error("HTTP "+r.status);
    const j = await r.json();
    items = (j.products || j || []).filter(p=>p && p.image);
  }catch(err){
    // Standalone / offline: fall back to the embedded sample set so the
    // mechanic is always usable. (Same-origin from :7690 the live fetch works.)
    console.warn("[swipe-tower] live API unavailable, using offline sample set:", err.message);
    items = FALLBACK.slice();
  }
  if(!items.length) items = FALLBACK.slice();
  if(reshuffle){ items = items.slice().sort(()=>Math.random()-0.5); }
  queue = items;
  history = []; seen = 0;
  $("loading").style.display = "none";
  renderDeck();
}
load();
</script>
</body>
</html>