← 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 <kbd>→</kbd> keep <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=>({'&':'&','<':'<','>':'>','"':'"'}[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)}″ · ${cw?esc(cw):"swatch"}</div>
</div>
<button class="rm" title="Remove" aria-label="Remove">×</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>