← back to Quadrille Showroom

public/proto/sample-table.html

477 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<title>SAMPLE TABLE — China Seas</title>
<style>
  :root{
    --ink:#1a1206; --paper:#f0e6cf;
    --plaster:#e7e1d6; --stone:#cfc7b8; --greige:#b8afa0;
    --brass:#b9933f; --brass-lt:#c9a96e;
    --oak:#9a7a52; --oak-dk:#6f5536; --oak-lt:#b58e5c;
    --charcoal:#1c1c22;
    --rail-h:184px;
  }
  *{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
  html,body{ margin:0; height:100%; overflow:hidden; background:#15110c;
    font-family:"Cormorant Garamond","Hoefler Text",Georgia,"Times New Roman",serif;
    color:var(--paper); }
  button{ font-family:inherit; }

  /* ───────── the room behind the table (limewash wall) ───────── */
  #scene{ position:fixed; inset:0; overflow:hidden;
    /* warm limewash plaster wall up top, warm oak floor below the table line */
    background:
      radial-gradient(120% 90% at 50% -8%, rgba(255,247,231,.10), rgba(0,0,0,0) 55%),
      linear-gradient(180deg,
        #d8d0c2 0%, #cdc4b4 30%, #c2b8a6 46%,
        #8a6c47 46.001%, #7a5e3c 64%, #5e4830 100%); }
  /* faint plaster mottle */
  #scene::before{ content:""; position:absolute; inset:0;
    background:
      radial-gradient(40% 30% at 22% 20%, rgba(255,255,255,.05), rgba(0,0,0,0) 70%),
      radial-gradient(46% 34% at 78% 14%, rgba(0,0,0,.04), rgba(0,0,0,0) 70%);
    mix-blend-mode:overlay; pointer-events:none; }
  /* picture-light graze + soft vignette */
  #scene::after{ content:""; position:absolute; inset:0; pointer-events:none;
    background:
      radial-gradient(80% 40% at 50% 4%, rgba(255,236,200,.16), rgba(0,0,0,0) 60%),
      radial-gradient(120% 120% at 50% 60%, rgba(0,0,0,0) 55%, rgba(20,16,11,.42) 100%); }

  /* brass picture rail across the wall behind */
  .wallrail{ position:absolute; left:0; right:0; top:18%; height:4px; z-index:1;
    background:linear-gradient(180deg,#e7cf97,#b9933f 55%,#7d6228);
    box-shadow:0 1px 0 rgba(255,255,255,.18), 0 6px 14px -6px rgba(0,0,0,.5); }

  /* ───────── the consultation table (CSS-3D, perspective floor) ─────────
     The whole stage sits ABOVE the rail; the table's near edge tucks just
     behind the rail so a seated person looks ACROSS the oak. A moderate
     rotateX (not extreme) keeps the top reading as a real receding surface. */
  #stage{ position:fixed; left:0; right:0; bottom:0; top:0; z-index:2;
    perspective:1150px; perspective-origin:50% 36%; pointer-events:none; }
  /* the oval oak tabletop laid flat in perspective, filling the foreground */
  #table{ position:absolute; left:50%; top:30%;
    width:175vw; height:96vh; max-width:2100px;
    transform:translate(-50%,0) rotateX(54deg);
    transform-style:preserve-3d; pointer-events:auto; }
  .tabletop{ position:absolute; inset:0; border-radius:50%/46%;
    background:
      radial-gradient(70% 80% at 50% 22%, rgba(255,238,210,.20), rgba(0,0,0,0) 58%),
      repeating-linear-gradient(91deg,
        #9c7c54 0 30px, #8f7049 30px 60px, #a3815770 60px 92px),
      linear-gradient(178deg,#ab8a5e 0%,#977654 42%,#6f5536 100%);
    box-shadow:
      inset 0 14px 40px rgba(255,240,214,.12),
      inset 0 0 160px 50px rgba(48,32,15,.5),
      inset 0 -40px 90px rgba(40,26,12,.45),
      0 70px 110px -24px rgba(0,0,0,.65);
    border:1px solid rgba(58,40,20,.5); }
  /* satin sheen sweep + a darker far-edge so the oval reads as a solid object */
  .tabletop::after{ content:""; position:absolute; inset:0; border-radius:50%/46%;
    background:
      linear-gradient(116deg, rgba(255,246,228,.22) 0%, rgba(255,246,228,0) 30%),
      radial-gradient(58% 50% at 50% 86%, rgba(255,238,212,.10), rgba(0,0,0,0) 60%);
    mix-blend-mode:screen; pointer-events:none; }
  /* the layer the sample squares live on — SAME 3D plane as the tabletop */
  #surface{ position:absolute; inset:0; transform-style:preserve-3d; }

  /* a sample square sitting flat on the table (already in the rotated plane) */
  .sample{ position:absolute; width:210px; height:210px; margin:-105px 0 0 -105px;
    transform-style:preserve-3d; cursor:grab; will-change:transform,opacity;
    pointer-events:auto; }
  .sample .paper{ position:absolute; inset:0; border-radius:3px;
    background:#fbf7ee; padding:7px 7px 26px;
    box-shadow:
      0 1px 0 rgba(255,255,255,.7) inset,
      0 28px 36px -12px rgba(20,14,6,.55),
      0 8px 14px -6px rgba(20,14,6,.5);
    border:1px solid rgba(120,96,60,.35); }
  .sample .swatch{ position:absolute; left:7px; right:7px; top:7px; bottom:26px;
    border-radius:2px; background-size:cover; background-position:center;
    box-shadow:inset 0 0 0 1px rgba(0,0,0,.06); }
  .sample .plate{ position:absolute; left:7px; right:7px; bottom:6px; height:16px;
    display:flex; align-items:center; justify-content:space-between; gap:6px;
    font-family:"Cormorant Garamond",Georgia,serif; }
  .sample .pname{ font-size:11px; line-height:1; color:#2b2114; letter-spacing:.2px;
    white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:74%; }
  .sample .pcol{ font-size:8.5px; letter-spacing:1px; text-transform:uppercase;
    color:#9a7a52; white-space:nowrap; }
  /* a soft contact shadow puddle under each square, on the table plane */
  .sample .contact{ position:absolute; left:6%; right:6%; top:14%; bottom:-6%;
    border-radius:50%; background:rgba(20,12,4,.34);
    filter:blur(11px); transform:translateZ(-2px); z-index:-1; }
  /* remove (×) handle — appears on hover/touch */
  .sample .rm{ position:absolute; top:-13px; right:-13px; width:30px; height:30px;
    border-radius:50%; border:1.5px solid #fff; background:#1c1c22; color:#fff;
    font:700 16px/1 -apple-system,Segoe UI,sans-serif; cursor:pointer;
    display:none; align-items:center; justify-content:center;
    box-shadow:0 6px 16px -4px rgba(0,0,0,.6); transform:translateZ(28px); }
  .sample:hover .rm{ display:flex; }
  .sample.lifted{ cursor:grabbing; }
  @keyframes flyIn{
    0%   { opacity:0; }
    12%  { opacity:1; }
    100% { opacity:1; }
  }

  /* ───────── browse rail (the swatch book strip) ───────── */
  .rail{ position:fixed; left:0; right:0; bottom:0; height:var(--rail-h); z-index:6;
    display:flex; gap:14px; align-items:stretch; padding:14px 18px 16px;
    overflow-x:auto; overflow-y:hidden;
    background:linear-gradient(to top, rgba(18,14,9,.96) 60%, rgba(18,14,9,.78));
    border-top:1px solid rgba(201,169,110,.35);
    box-shadow:0 -18px 40px -16px rgba(0,0,0,.7);
    scrollbar-width:thin; }
  .rail::-webkit-scrollbar{ height:8px; }
  .rail::-webkit-scrollbar-thumb{ background:rgba(201,169,110,.4); border-radius:4px; }
  .card{ flex:0 0 auto; width:128px; display:flex; flex-direction:column; gap:7px; }
  .card .chip{ position:relative; width:128px; height:88px; border-radius:5px;
    background:#2a2620 center/cover no-repeat; border:1px solid rgba(201,169,110,.3);
    box-shadow:0 6px 16px -8px rgba(0,0,0,.6); }
  .card .nm{ font-size:13.5px; line-height:1.15; color:#efe6d0; letter-spacing:.2px;
    height:31px; overflow:hidden; }
  .card .add{ width:100%; min-height:46px; border-radius:9px; cursor:pointer;
    border:1.5px solid var(--brass); color:#1a1206; letter-spacing:.4px;
    font:700 16px/1 "Cormorant Garamond",Georgia,serif;
    background:linear-gradient(180deg,#e3cb95,#c19a44);
    box-shadow:0 7px 18px -8px rgba(0,0,0,.55); transition:transform .1s,filter .1s; }
  .card .add:hover{ filter:brightness(1.05); transform:translateY(-1px); }
  .card .add:active{ transform:translateY(1px); }

  /* ───────── top brand bar ───────── */
  .topbar{ position:fixed; top:0; left:0; right:0; z-index:7;
    display:flex; align-items:center; justify-content:space-between;
    padding:16px 22px 28px; pointer-events:none;
    background:linear-gradient(to bottom, rgba(20,16,11,.55), rgba(20,16,11,0)); }
  .brand{ letter-spacing:.34em; font-size:12px; text-transform:uppercase;
    opacity:.92; font-family:-apple-system,Segoe UI,sans-serif; font-weight:600; }
  .brand b{ color:var(--brass-lt); font-weight:700; }

  /* ───────── basket / count card (right) ───────── */
  .basket{ position:fixed; right:18px; top:64px; z-index:8; width:230px;
    background:rgba(16,13,9,.92); border:1.5px solid rgba(201,169,110,.45);
    border-radius:14px; padding:14px 15px 15px; backdrop-filter:blur(10px);
    box-shadow:0 18px 50px -14px rgba(0,0,0,.7); }
  .basket .eyebrow{ font:600 10.5px/1 -apple-system,Segoe UI,sans-serif;
    letter-spacing:2.4px; text-transform:uppercase; color:#a7a08f; }
  .basket .count{ display:flex; align-items:baseline; gap:8px; margin:6px 0 12px; }
  .basket .count b{ font:400 40px/1 "Cormorant Garamond",Georgia,serif; color:#efe6d0; }
  .basket .count span{ font-size:14px; color:#bdb39c; letter-spacing:.5px; }
  .basket .req{ width:100%; min-height:50px; border-radius:11px; cursor:pointer;
    border:1.5px solid var(--brass); color:#1a1206; letter-spacing:.4px;
    font:700 16px/1.1 "Cormorant Garamond",Georgia,serif;
    background:linear-gradient(180deg,#e3cb95,#c19a44);
    transition:filter .12s,transform .1s; }
  .basket .req:disabled{ filter:grayscale(.6) brightness(.85); cursor:default;
    border-color:rgba(201,169,110,.4); }
  .basket .req:not(:disabled):hover{ filter:brightness(1.05); }
  .basket .req:not(:disabled):active{ transform:translateY(1px); }
  .basket .hint{ margin-top:9px; font-size:11px; color:#9a937f; line-height:1.4;
    font-family:-apple-system,Segoe UI,sans-serif; }

  /* table caption — engraved placard feel */
  .caption{ position:fixed; left:50%; top:9%; transform:translateX(-50%); z-index:5;
    text-align:center; pointer-events:none; }
  .caption .k{ font:600 10.5px/1 -apple-system,Segoe UI,sans-serif;
    letter-spacing:3px; text-transform:uppercase; color:#7c6a48; }
  .caption .t{ margin-top:6px; font-size:26px; color:#efe6d0; letter-spacing:.5px;
    text-shadow:0 2px 14px rgba(0,0,0,.4); }

  /* toast when basket requested */
  .toast{ position:fixed; left:50%; bottom:calc(var(--rail-h) + 22px);
    transform:translateX(-50%) translateY(20px); z-index:9; opacity:0;
    pointer-events:none; transition:opacity .3s,transform .3s;
    background:rgba(20,16,11,.95); border:1.5px solid var(--brass);
    color:#efe6d0; padding:14px 22px; border-radius:30px; font-size:16px;
    letter-spacing:.4px; box-shadow:0 14px 40px -10px rgba(0,0,0,.7); }
  .toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }

  .loading{ position:fixed; inset:0; z-index:30; display:flex; gap:14px;
    align-items:center; justify-content:center; flex-direction:column;
    background:#15110c; transition:opacity .5s; }
  .loading.gone{ opacity:0; pointer-events:none; }
  .ring{ width:32px; height:32px; border:2px solid rgba(240,230,207,.2);
    border-top-color:#c9a96e; border-radius:50%; animation:spin 1s linear infinite; }
  @keyframes spin{ to{ transform:rotate(360deg); } }
  .loading p{ letter-spacing:.28em; text-transform:uppercase; font-size:11px;
    color:#bdb39c; font-family:-apple-system,Segoe UI,sans-serif; }

  @media(max-width:760px){
    .basket{ width:170px; top:58px; right:12px; }
    .basket .count b{ font-size:32px; }
    #table{ width:188vw; }
  }
</style>
</head>
<body>
  <div id="scene"><div class="wallrail"></div></div>

  <div id="stage">
    <div id="table">
      <div class="tabletop"></div>
      <div id="surface"></div>
    </div>
  </div>

  <div class="topbar">
    <div class="brand"><b>The Quadrille House</b> &nbsp;·&nbsp; Sample Table</div>
    <div></div>
  </div>

  <div class="caption">
    <div class="k">China Seas · Consultation</div>
    <div class="t">Lay your samples on the table</div>
  </div>

  <div class="basket">
    <div class="eyebrow">Samples on table</div>
    <div class="count"><b id="count">0</b><span id="countWord">samples</span></div>
    <button class="req" id="req" disabled>Request these samples</button>
    <div class="hint">Tap <b>+ Add Sample</b> below. Drag a square to move it, or hover and tap × to put it back.</div>
  </div>

  <div class="rail" id="rail"></div>

  <div class="toast" id="toast"></div>

  <div class="loading" id="loading"><div class="ring"></div><p>Setting the table…</p></div>

  <!-- version-picker integration (Back button + numbered-element legend) -->
  <script>
    window.PROTO_META = {
      key: 'V11', label: 'Sample Table',
      elements: [
        { n: 1, label: 'Sit at a warm oak consultation table (POV)' },
        { n: 2, label: '"+ Add Sample" drops the square swatch ONTO the table' },
        { n: 3, label: 'Samples land flat-in-perspective + spread naturally' },
        { n: 4, label: 'Drag to rearrange · × to remove a sample' },
        { n: 5, label: 'Running count + "Request these samples" basket' }
      ]
    };
  </script>
  <script src="./proto-chrome.js"></script>

  <script>
  (function(){
    "use strict";
    var API = '/api/showroom/products?limit=200';
    var rail   = document.getElementById('rail');
    var surface= document.getElementById('surface');
    var tableEl= document.getElementById('table');
    var countEl= document.getElementById('count');
    var wordEl = document.getElementById('countWord');
    var reqBtn = document.getElementById('req');
    var toastEl= document.getElementById('toast');

    // table-local coordinate frame. The #table element is rotateX(64deg); the
    // #surface fills it, so any child placed in surface px-coords inherits the
    // SAME perspective — squares are genuinely lying on the tabletop plane, not
    // pinned to the screen. We place samples within a centred ellipse so they
    // pool on the visible top of the table.
    var placed = [];            // {el, sku} list of samples on the table
    var slotIndex = 0;          // drives the natural spiral-ish spread

    function tableSize(){ return { w: tableEl.clientWidth, h: tableEl.clientHeight }; }

    // a pseudo-random-but-stable spread: golden-angle spiral from table centre,
    // biased toward the near (lower) half of the oval where a seated person
    // would actually lay swatches. Returns {x,y} in #surface px + a rotation.
    function nextSlot(){
      var s = tableSize();
      // Because the table is steeply foreshortened, the screen-visible band of
      // the oak (above the rail, in front of the seated viewer) maps to the
      // UPPER-MIDDLE of the table's own surface coordinates. Pool the swatches
      // there so they land where the eye actually sees the tabletop.
      var cx = s.w*0.5, cy = s.h*0.40;
      var i = slotIndex++;
      var ang = i * 2.399963;                       // golden angle (rad)
      var rad = Math.min(s.w,s.h) * 0.072 * Math.sqrt(i+0.6);
      rad = Math.min(rad, Math.min(s.w,s.h)*0.22);
      // jitter so it never looks like a perfect math spiral
      var jx = ((Math.sin(i*12.9898)*43758.5453) % 1) * 80 - 40;
      var jy = ((Math.sin(i*78.233 )*12543.1234) % 1) * 44 - 22;
      var x = cx + Math.cos(ang)*rad*1.25 + jx;     // spread wider than deep
      var y = cy + Math.sin(ang)*rad*0.55 + jy;     // squash y → oval pooling
      var rot = (((Math.sin(i*3.1)*1000)) % 1)*36 - 18; // ±18° lay rotation
      return { x:x, y:y, rot:rot };
    }

    function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g,function(m){
      return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]; }); }
    function imgUrl(u){
      if(!u) return '';
      if(u.charAt(0)==='/') return u;               // local /gen-tiles etc.
      return '/api/proxy/image?url=' + encodeURIComponent(u);
    }
    function colorLabel(p){
      if(p.color_name) return p.color_name;
      if(p.color_bucket) return p.color_bucket;
      // derive a short colorway from the pattern name tail (" on White" etc.)
      var m = /\bon\s+([A-Za-z ]{3,22})$/.exec((p.pattern_name||'').replace(/\s*Screen.*$/i,''));
      return m ? m[1].trim() : (p.collection || 'China Seas');
    }
    function shortName(p){
      var n = (p.pattern_name||p.title||'').replace(/\s*-\s*Off-White$/i,'')
              .replace(/\bScreen Printed\b/i,'').replace(/\s+on\s+.*$/i,'').trim();
      return n || 'China Seas';
    }

    function updateCount(){
      var n = placed.length;
      countEl.textContent = n;
      wordEl.textContent = n===1 ? 'sample' : 'samples';
      reqBtn.disabled = n===0;
    }

    function toast(msg){
      toastEl.textContent = msg; toastEl.classList.add('show');
      clearTimeout(toast._t); toast._t = setTimeout(function(){ toastEl.classList.remove('show'); }, 2600);
    }

    // ───────── drop a sample onto the table ─────────
    function addSample(p, fromEl){
      var slot = nextSlot();
      var el = document.createElement('div');
      el.className = 'sample';
      el.dataset.sku = p.sku || '';
      el.innerHTML =
        '<div class="contact"></div>' +
        '<div class="paper">' +
          '<div class="swatch" style="background-image:url(\'' + imgUrl(p.image||p.tile) + '\')"></div>' +
          '<div class="plate">' +
            '<span class="pname">' + escapeHtml(shortName(p)) + '</span>' +
            '<span class="pcol">' + escapeHtml(colorLabel(p)) + '</span>' +
          '</div>' +
        '</div>' +
        '<button class="rm" title="Remove">×</button>';

      // start the square small, "in the air" above its slot (translateZ lifts it
      // off the table plane), then settle it flat. Because it lives INSIDE the
      // rotated #surface, the lift + landing happen in the table's own 3D space.
      var land = 'translate3d('+slot.x+'px,'+slot.y+'px,0) rotateZ('+slot.rot.toFixed(1)+'deg)';
      var lift = 'translate3d('+slot.x+'px,'+(slot.y-70)+'px,150px) rotateZ('+(slot.rot-22).toFixed(1)+'deg) scale(1.06)';
      el.style.transform = lift;
      el.style.opacity = '0';
      surface.appendChild(el);

      // force reflow then animate to the landed transform
      // eslint-disable-next-line no-unused-expressions
      el.offsetWidth;
      el.style.transition = 'transform .62s cubic-bezier(.22,1.08,.36,1), opacity .3s ease-out';
      el.style.opacity = '1';
      el.style.transform = land;

      // tiny settle bounce after landing
      setTimeout(function(){
        el.style.transition = 'transform .16s ease-out';
        el.style.transform = 'translate3d('+slot.x+'px,'+(slot.y+3)+'px,0) rotateZ('+slot.rot.toFixed(1)+'deg)';
        setTimeout(function(){
          el.style.transform = land;
          el.dataset.x = slot.x; el.dataset.y = slot.y; el.dataset.rot = slot.rot;
        }, 160);
      }, 600);

      // remove handle
      el.querySelector('.rm').addEventListener('click', function(ev){
        ev.stopPropagation();
        el.style.transition = 'transform .4s ease-in, opacity .4s ease-in';
        el.style.transform = 'translate3d('+slot.x+'px,'+(slot.y-90)+'px,180px) scale(.9)';
        el.style.opacity = '0';
        setTimeout(function(){ el.remove(); }, 380);
        placed = placed.filter(function(r){ return r.el!==el; });
        updateCount();
      });

      enableDrag(el);
      placed.push({ el:el, sku:p.sku });
      updateCount();

      // little nudge on the source button
      if(fromEl){ fromEl.style.transform='translateY(2px)';
        setTimeout(function(){ fromEl.style.transform=''; }, 120); }
    }

    // ───────── drag a placed sample around the table plane ─────────
    // Pointer moves are in SCREEN px; the table plane is rotateX(64°), so a
    // screen-Y delta maps to a larger table-Y delta. We divide vertical movement
    // by cos(64°) so dragging tracks the surface, keeping it glued to the table.
    var COS = Math.cos(54*Math.PI/180);   // ≈ 0.588 — matches table rotateX
    function enableDrag(el){
      var sx,sy,ox,oy,rot,active=false;
      function down(e){
        if(e.target.classList.contains('rm')) return;
        active=true; el.classList.add('lifted');
        var pt = e.touches?e.touches[0]:e;
        sx=pt.clientX; sy=pt.clientY;
        ox=parseFloat(el.dataset.x)||0; oy=parseFloat(el.dataset.y)||0;
        rot=parseFloat(el.dataset.rot)||0;
        // bring to front + a slight lift off the table while held
        surface.appendChild(el);
        el.style.transition='transform .12s ease-out';
        el.style.transform='translate3d('+ox+'px,'+oy+'px,40px) rotateZ('+rot+'deg) scale(1.04)';
        e.preventDefault();
        window.addEventListener('mousemove',move); window.addEventListener('mouseup',up);
        window.addEventListener('touchmove',move,{passive:false}); window.addEventListener('touchend',up);
      }
      function move(e){
        if(!active) return;
        var pt = e.touches?e.touches[0]:e;
        var nx = ox + (pt.clientX - sx);
        var ny = oy + (pt.clientY - sy)/COS;       // un-foreshorten vertical drag
        el.style.transition='none';
        el.style.transform='translate3d('+nx+'px,'+ny+'px,40px) rotateZ('+rot+'deg) scale(1.04)';
        el.dataset.x=nx; el.dataset.y=ny;
        e.preventDefault();
      }
      function up(){
        if(!active) return; active=false; el.classList.remove('lifted');
        var nx=parseFloat(el.dataset.x)||0, ny=parseFloat(el.dataset.y)||0;
        el.style.transition='transform .2s cubic-bezier(.22,1,.36,1)';
        el.style.transform='translate3d('+nx+'px,'+ny+'px,0) rotateZ('+rot+'deg)';  // settle flat
        window.removeEventListener('mousemove',move); window.removeEventListener('mouseup',up);
        window.removeEventListener('touchmove',move); window.removeEventListener('touchend',up);
      }
      el.addEventListener('mousedown',down);
      el.addEventListener('touchstart',down,{passive:false});
    }

    // ───────── build the browse rail ─────────
    function buildRail(products){
      var frag = document.createDocumentFragment();
      products.forEach(function(p){
        var card = document.createElement('div'); card.className='card';
        var chip = document.createElement('div'); chip.className='chip';
        chip.style.backgroundImage = "url('"+imgUrl(p.image||p.tile)+"')";
        var nm = document.createElement('div'); nm.className='nm';
        nm.textContent = shortName(p) + ' · ' + colorLabel(p);
        var add = document.createElement('button'); add.className='add';
        add.textContent = '+ Add Sample';
        add.addEventListener('click', function(){ addSample(p, add); });
        card.appendChild(chip); card.appendChild(nm); card.appendChild(add);
        frag.appendChild(card);
      });
      rail.appendChild(frag);
    }

    reqBtn.addEventListener('click', function(){
      if(!placed.length) return;
      var skus = placed.map(function(r){ return r.sku; }).filter(Boolean);
      try{ localStorage.setItem('qh_sample_basket', JSON.stringify(skus)); }catch(e){}
      toast('Memo samples requested — ' + placed.length + ' on their way ✦');
    });

    // ───────── load data ─────────
    fetch(API).then(function(r){ return r.json(); }).then(function(d){
      var products = (d.products||d||[]).filter(function(p){ return p.image||p.tile; });
      buildRail(products);
      document.getElementById('loading').classList.add('gone');
    }).catch(function(err){
      console.error('sample-table load failed', err);
      document.getElementById('loading').querySelector('p').textContent = 'Could not load samples';
    });
  })();
  </script>
</body>
</html>