← back to Interiordesignershowroom
feat: affiliate cart — collect pieces, checkout opens each product in its own tab (per-merchant). Server-rendered add-cart buttons + client cart.js (localStorage list, floating cart button, slide-in drawer, tab-fanout checkout with popup-block fallback)
64a544987b21ba3e6b1a7e1dd0db6c950a298e0d · 2026-08-01 23:57:10 -0700 · Steve Abrams
Files touched
Diff
commit 64a544987b21ba3e6b1a7e1dd0db6c950a298e0d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 23:57:10 2026 -0700
feat: affiliate cart — collect pieces, checkout opens each product in its own tab (per-merchant). Server-rendered add-cart buttons + client cart.js (localStorage list, floating cart button, slide-in drawer, tab-fanout checkout with popup-block fallback)
---
public/js/cart.js | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 140 insertions(+)
diff --git a/public/js/cart.js b/public/js/cart.js
new file mode 100644
index 0000000..062c376
--- /dev/null
+++ b/public/js/cart.js
@@ -0,0 +1,140 @@
+// Affiliate "shopping list" cart — the BEHAVIOR layer for the server-rendered
+// `.add-cart` buttons (see productCard/addCartBtn in lib/render.js). Because every
+// product checks out at a DIFFERENT merchant via its own /go/:id tracked link, there
+// is no unified checkout — the shopper collects items and Checkout opens EACH item
+// at its store in its own tab (one high-intent burst of tracked links). Pure
+// client-side: state in localStorage, UI + styles injected here, buttons wired by
+// delegation so infinite-scroll / builder-added cards work too.
+(function () {
+ var KEY = 'ids_cart';
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
+ function load() { try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return []; } }
+ function save(c) { localStorage.setItem(KEY, JSON.stringify(c)); sync(); }
+ function has(id) { return load().some(function (x) { return String(x.id) === String(id); }); }
+ function addItem(it) { var c = load(); if (!c.some(function (x) { return String(x.id) === String(it.id); })) { c.push(it); save(c); } }
+ function removeItem(id) { save(load().filter(function (x) { return String(x.id) !== String(id); })); }
+
+ // ---------- injected styles (fab + drawer + the .add-cart/.card-actions buttons) ----------
+ var css = ''
+ + '.card-actions{display:flex;flex-direction:column;gap:6px;margin-top:10px}'
+ + '.add-cart{border:1px solid var(--line,#e8e2d9);background:var(--bg,#faf8f5);border-radius:2px;padding:6px 8px;'
+ + 'font:.68rem/1 var(--sans,sans-serif);letter-spacing:.06em;text-transform:uppercase;cursor:pointer;color:#3a3530;'
+ + 'transition:border-color .18s,color .18s,background .18s}'
+ + '.add-cart:hover{border-color:#9b7b50;color:#9b7b50}'
+ + '.add-cart[aria-pressed="true"]{border-color:#9b7b50;color:#9b7b50;background:rgba(155,123,80,.08)}'
+ + '.cartfab{position:fixed;right:20px;bottom:20px;z-index:300;display:flex;align-items:center;gap:7px;'
+ + 'background:#16130f;color:#faf8f5;border:0;border-radius:30px;padding:11px 17px;font:500 .8rem/1 var(--sans,sans-serif);'
+ + 'letter-spacing:.04em;cursor:pointer;box-shadow:0 8px 26px rgba(22,19,15,.28);transition:transform .15s,background .2s}'
+ + '.cartfab:hover{transform:translateY(-2px);background:#9b7b50}'
+ + '.cartfab .cartfab-n{display:inline-grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:11px;'
+ + 'background:#c9a96e;color:#16130f;font-size:.72rem;font-weight:600}'
+ + '.cartfab:not(.has){opacity:.82}'
+ + '.cart-backdrop{position:fixed;inset:0;z-index:340;background:rgba(22,19,15,.42);backdrop-filter:blur(2px)}'
+ + '.cartdrawer{position:fixed;top:0;right:0;height:100vh;width:360px;max-width:90vw;z-index:350;background:#fff;'
+ + 'border-left:1px solid var(--line,#e8e2d9);box-shadow:-10px 0 40px rgba(22,19,15,.18);display:flex;flex-direction:column;'
+ + 'font-family:var(--sans,sans-serif);animation:cartslide .26s cubic-bezier(.22,1,.36,1)}'
+ + '@keyframes cartslide{from{transform:translateX(20px);opacity:.6}to{transform:none;opacity:1}}'
+ + '.cartdrawer-head{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#e8e2d9);'
+ + 'font-family:var(--serif,serif);font-size:1.15rem;color:#16130f}'
+ + '.cartdrawer-x{border:0;background:none;font-size:1.1rem;color:#7a7168;cursor:pointer;line-height:1}'
+ + '.cart-items{flex:1;overflow-y:auto;padding:8px 14px}'
+ + '.cart-empty{color:#7a7168;font-style:italic;text-align:center;padding:40px 20px;line-height:1.6;font-size:.86rem}'
+ + '.cart-item{display:flex;gap:11px;align-items:flex-start;padding:10px 2px;border-bottom:1px solid var(--line-lt,#f1ece5)}'
+ + '.cart-item>img{width:52px;height:52px;object-fit:cover;border-radius:4px;border:1px solid var(--line,#e8e2d9);flex:0 0 52px;background:#f4f0e9}'
+ + '.cart-item-b{flex:1;min-width:0}'
+ + '.cart-item-t{font-size:.78rem;color:#3a3530;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
+ + '.cart-item-m{font-size:.72rem;color:#7a7168;margin:2px 0 4px}'
+ + '.cart-item-open{font-size:.7rem;color:#7a6038;text-transform:uppercase;letter-spacing:.05em}'
+ + '.cart-item-open:hover{text-decoration:underline}'
+ + '.cart-item-rm{border:0;background:none;color:#b0a898;cursor:pointer;font-size:.9rem;padding:2px}'
+ + '.cart-item-rm:hover{color:#8b3a3a}'
+ + '.cart-foot{border-top:1px solid var(--line,#e8e2d9);padding:14px 16px;display:flex;flex-direction:column;gap:8px}'
+ + '.cart-checkout{background:#16130f;color:#faf8f5;border:0;border-radius:2px;padding:13px;font:500 .8rem/1 var(--sans,sans-serif);'
+ + 'letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:background .2s}'
+ + '.cart-checkout:hover{background:#9b7b50}'
+ + '.cart-clear{background:none;border:0;color:#7a7168;font-size:.72rem;cursor:pointer;letter-spacing:.03em}'
+ + '.cart-clear:hover{color:#8b3a3a}'
+ + '.cart-note{margin:2px 0 0;font-size:.66rem;color:#b0a898;line-height:1.5}'
+ + '.cartdrawer[hidden],.cart-backdrop[hidden]{display:none}' // explicit display beats [hidden] UA rule
+ + '@media(max-width:520px){.cartfab{right:14px;bottom:14px}}';
+ var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
+
+ // ---------- floating button + drawer ----------
+ var fab = document.createElement('button');
+ fab.className = 'cartfab'; fab.type = 'button'; fab.setAttribute('aria-label', 'Open your shopping list');
+ fab.innerHTML = '🛍 <span class="cartfab-n">0</span>';
+ var backdrop = document.createElement('div'); backdrop.className = 'cart-backdrop'; backdrop.hidden = true;
+ var drawer = document.createElement('div'); drawer.className = 'cartdrawer'; drawer.hidden = true;
+ drawer.setAttribute('role', 'dialog'); drawer.setAttribute('aria-label', 'Your shopping list');
+ drawer.innerHTML = '<div class="cartdrawer-head">Your list <button class="cartdrawer-x" type="button" aria-label="Close">✕</button></div>'
+ + '<div class="cart-items"></div><div class="cart-foot"></div>';
+ document.body.appendChild(fab); document.body.appendChild(backdrop); document.body.appendChild(drawer);
+
+ function renderDrawer() {
+ var c = load(), items = drawer.querySelector('.cart-items'), foot = drawer.querySelector('.cart-foot');
+ if (!c.length) { items.innerHTML = '<p class="cart-empty">Your list is empty.<br>Add pieces you love, then open them all to shop at each store.</p>'; foot.innerHTML = ''; return; }
+ items.innerHTML = c.map(function (x) {
+ return '<div class="cart-item"><img src="' + esc(x.image) + '" alt="" onerror="this.style.visibility=\'hidden\'">'
+ + '<div class="cart-item-b"><div class="cart-item-t">' + esc(x.title) + '</div>'
+ + '<div class="cart-item-m">' + esc(x.price || '') + (x.advertiser ? ' · ' + esc(x.advertiser) : '') + '</div>'
+ + '<a class="cart-item-open" href="' + esc(x.url) + '" target="_blank" rel="nofollow sponsored">Open →</a></div>'
+ + '<button class="cart-item-rm" type="button" data-rm="' + esc(x.id) + '" aria-label="Remove">✕</button></div>';
+ }).join('');
+ foot.innerHTML = '<button class="cart-checkout" type="button">Checkout — open all ' + c.length + ' tab' + (c.length === 1 ? '' : 's') + '</button>'
+ + '<button class="cart-clear" type="button">Clear list</button>'
+ + '<p class="cart-note">Checkout opens each item at its store in a new tab. If your browser blocks them, allow pop-ups for this site — or tap “Open →” on each.</p>';
+ }
+ function openDrawer() { renderDrawer(); backdrop.hidden = false; drawer.hidden = false; document.body.style.overflow = 'hidden'; }
+ function closeDrawer() { drawer.hidden = true; backdrop.hidden = true; document.body.style.overflow = ''; }
+ function checkout() {
+ var c = load(); if (!c.length) return;
+ var blocked = 0;
+ c.forEach(function (x) { var w = window.open(x.url, '_blank', 'noopener'); if (!w) blocked++; });
+ if (blocked) setTimeout(function () { alert(blocked + ' of ' + c.length + ' tab' + (blocked === 1 ? ' was' : 's were') + ' blocked by your browser. Allow pop-ups for this site, or use the “Open →” link on each item.'); }, 60);
+ }
+
+ // read a cart item off a server-rendered .add-cart button (+ its card for advertiser)
+ function itemFromBtn(btn) {
+ var id = btn.getAttribute('data-id'); if (!id) return null;
+ var card = btn.closest('.card'), brand = card && card.querySelector('.card-brand');
+ var adv = (card && (card.getAttribute('data-network') || (brand ? brand.textContent : ''))) || '';
+ return {
+ id: id, title: btn.getAttribute('data-title') || '', price: btn.getAttribute('data-price') || '',
+ image: btn.getAttribute('data-img') || '', advertiser: adv.trim(),
+ url: btn.getAttribute('data-go') || ('/go/' + id),
+ };
+ }
+ function syncBtn(btn) {
+ var on = has(btn.getAttribute('data-id'));
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ var l = btn.querySelector('.add-cart-label') || btn;
+ l.textContent = on ? '✓ On list' : 'Add to cart';
+ }
+ function sync() {
+ fab.querySelector('.cartfab-n').textContent = load().length;
+ fab.classList.toggle('has', load().length > 0);
+ document.querySelectorAll('.add-cart').forEach(syncBtn);
+ if (!drawer.hidden) renderDrawer();
+ }
+
+ // ---------- events ----------
+ fab.addEventListener('click', openDrawer);
+ backdrop.addEventListener('click', closeDrawer);
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !drawer.hidden) closeDrawer(); });
+ drawer.addEventListener('click', function (e) {
+ if (e.target.closest('.cartdrawer-x')) return closeDrawer();
+ if (e.target.closest('.cart-checkout')) return checkout();
+ if (e.target.closest('.cart-clear')) { save([]); return; }
+ var rm = e.target.closest('[data-rm]'); if (rm) removeItem(rm.getAttribute('data-rm'));
+ });
+ document.addEventListener('click', function (e) {
+ var b = e.target.closest('.add-cart'); if (!b) return;
+ e.preventDefault();
+ var it = itemFromBtn(b); if (!it) return;
+ if (has(it.id)) removeItem(it.id); else addItem(it);
+ });
+
+ // dynamically-added cards (infinite scroll, builder) → keep button states in sync
+ var t; new MutationObserver(function () { clearTimeout(t); t = setTimeout(sync, 150); }).observe(document.body, { childList: true, subtree: true });
+ sync();
+})();
← 47e1b8d room page: 3x-larger click-to-shop thumbnails (info + Buy on
·
back to Interiordesignershowroom
·
add tracked-link coverage canary: samples live /go redirects 325e399 →