[object Object]

← back to CelebritySignatures

wear: real multi-item shopping cart (own cart, not Shopify's) — TK-10286

4173af9d82194319e0519365ff175a95430ec983 · 2026-09-08 13:54:37 -0700 · Steve Abrams

Steve: "create our own shopping cart for this build." Replaces the old
immediate single-item "Add to cart -> Stripe redirect" with an actual cart:
add multiple signatures/garments to a localStorage-backed cart, review/edit
in a cart panel, one Stripe Checkout session for everything at once.

1. public/wear.html: cart badge in the header, localStorage-backed cart
   (add/remove, persists across reloads), a cart panel (thumbnail + garment/
   color/size + price per line, remove button, running total, single email
   field). "Add to cart" no longer redirects immediately — it adds the
   current selection and lets the shopper keep browsing. The old per-item
   email field only shows in the NOT-live "notify me" state, unchanged from
   before; live sales use the cart's one email field instead.

2. server.js: POST /api/wear-checkout now accepts `items: [...]` (an array)
   instead of one flat item — validates every item (eligibility, garment/
   color/size), builds ONE Stripe Checkout Session with one line_item per
   cart item, stores the order as `items: [...]`. Kept a back-compat shim
   (a bare single-item body still works) so nothing else calling this
   endpoint breaks. Added WEAR_CART_MAX_ITEMS=20 (abuse guard, not a real
   limit concern).

3. New wearOrderItems(order) helper normalizes EITHER order shape (the new
   items[] array, or the old flat qid/garment/color/size fields from orders
   placed before 2026-09-08) into one array — /wear-success and
   reconcile-wear-orders.mjs both use it so old and new orders process
   identically. /wear-success now drafts ONE POD entry per cart line item,
   each with its own composite id ("<orderId>.<itemIndex>") so the existing
   per-draft idempotency/dedupe logic in the POD senders needs no changes —
   a multi-item order becomes N independent Printify orders to the same
   shipping address, which was the simplest correct architecture (no changes
   needed to submit-pod-draft-printify.mjs's per-draft submission logic).

Verified end-to-end with a real (throwaway, local-only) Playwright run:
faked WEAR_SALES_LIVE=1 + printify-provider env vars in a local process only
(never touched .env or real Stripe) to add 2 different items (tee + polo,
different signatures/colors/sizes) to the cart, confirmed badge count and
running total ($78.00 = $34+$44, correct), removed an item, re-added, and
completed checkout — got a REAL Stripe TEST session URL back, confirming the
multi-line-item session builds correctly. Verified the resulting order
recorded with the new items[] shape, and that the pre-existing single-item
test order (2026-08-10) still normalizes correctly through the same helper.
Test order removed from the gitignored data/wear-orders.json afterward.
Zero console errors throughout. Real environment re-verified afterward:
still gated off (comingSoon:true), all 4 garments present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm

Files touched

Diff

commit 4173af9d82194319e0519365ff175a95430ec983
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 13:54:37 2026 -0700

    wear: real multi-item shopping cart (own cart, not Shopify's) — TK-10286
    
    Steve: "create our own shopping cart for this build." Replaces the old
    immediate single-item "Add to cart -> Stripe redirect" with an actual cart:
    add multiple signatures/garments to a localStorage-backed cart, review/edit
    in a cart panel, one Stripe Checkout session for everything at once.
    
    1. public/wear.html: cart badge in the header, localStorage-backed cart
       (add/remove, persists across reloads), a cart panel (thumbnail + garment/
       color/size + price per line, remove button, running total, single email
       field). "Add to cart" no longer redirects immediately — it adds the
       current selection and lets the shopper keep browsing. The old per-item
       email field only shows in the NOT-live "notify me" state, unchanged from
       before; live sales use the cart's one email field instead.
    
    2. server.js: POST /api/wear-checkout now accepts `items: [...]` (an array)
       instead of one flat item — validates every item (eligibility, garment/
       color/size), builds ONE Stripe Checkout Session with one line_item per
       cart item, stores the order as `items: [...]`. Kept a back-compat shim
       (a bare single-item body still works) so nothing else calling this
       endpoint breaks. Added WEAR_CART_MAX_ITEMS=20 (abuse guard, not a real
       limit concern).
    
    3. New wearOrderItems(order) helper normalizes EITHER order shape (the new
       items[] array, or the old flat qid/garment/color/size fields from orders
       placed before 2026-09-08) into one array — /wear-success and
       reconcile-wear-orders.mjs both use it so old and new orders process
       identically. /wear-success now drafts ONE POD entry per cart line item,
       each with its own composite id ("<orderId>.<itemIndex>") so the existing
       per-draft idempotency/dedupe logic in the POD senders needs no changes —
       a multi-item order becomes N independent Printify orders to the same
       shipping address, which was the simplest correct architecture (no changes
       needed to submit-pod-draft-printify.mjs's per-draft submission logic).
    
    Verified end-to-end with a real (throwaway, local-only) Playwright run:
    faked WEAR_SALES_LIVE=1 + printify-provider env vars in a local process only
    (never touched .env or real Stripe) to add 2 different items (tee + polo,
    different signatures/colors/sizes) to the cart, confirmed badge count and
    running total ($78.00 = $34+$44, correct), removed an item, re-added, and
    completed checkout — got a REAL Stripe TEST session URL back, confirming the
    multi-line-item session builds correctly. Verified the resulting order
    recorded with the new items[] shape, and that the pre-existing single-item
    test order (2026-08-10) still normalizes correctly through the same helper.
    Test order removed from the gitignored data/wear-orders.json afterward.
    Zero console errors throughout. Real environment re-verified afterward:
    still gated off (comingSoon:true), all 4 garments present.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm
---
 public/wear.html                  | 119 +++++++++++++++++++++++++++++++++++---
 scripts/reconcile-wear-orders.mjs |  35 +++++++----
 server.js                         | 112 +++++++++++++++++++++--------------
 3 files changed, 207 insertions(+), 59 deletions(-)

diff --git a/public/wear.html b/public/wear.html
index e69b55b..7e2c17c 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -14,6 +14,18 @@
   .mast { font:italic 600 22px 'Playfair Display',serif; text-decoration:none; color:var(--ink); }
   header nav a { color:#6b6b6b; text-decoration:none; margin-left:16px; font-size:14px; }
   header nav a:hover { color:var(--ink); }
+  .cart-btn { margin-left:16px; font:inherit; font-size:14px; background:none; border:1px solid var(--line); border-radius:999px; padding:6px 14px; cursor:pointer; color:var(--ink); position:relative; }
+  .cart-btn:hover { border-color:var(--ink); }
+  .cart-badge { display:inline-block; margin-left:4px; background:var(--ink); color:#fff; border-radius:999px; font-size:11px; min-width:16px; padding:1px 5px; text-align:center; }
+  .cart-item { display:flex; gap:10px; align-items:center; padding:10px 0; border-bottom:1px solid var(--line); }
+  .cart-item img { width:44px; height:44px; object-fit:contain; background:#f7f5f0; border-radius:8px; padding:4px; mix-blend-mode:multiply; }
+  .cart-item .ci-info { flex:1; min-width:0; }
+  .cart-item .ci-name { font-size:14px; font-weight:500; }
+  .cart-item .ci-meta { font-size:12px; color:#6b6b6b; }
+  .cart-item .ci-price { font-size:14px; white-space:nowrap; }
+  .cart-item .ci-remove { background:none; border:0; color:#c0392b; cursor:pointer; font-size:18px; padding:0 4px; }
+  .cart-empty { text-align:center; color:#6b6b6b; padding:30px 0; }
+  .cart-total { display:flex; justify-content:space-between; font:600 18px 'Playfair Display',serif; padding:14px 0; border-top:1px solid var(--line); margin-top:6px; }
   .wrap { max-width:1200px; margin:0 auto; padding:22px; }
   h1 { font:400 34px/1.15 'Playfair Display',serif; margin:6px 0 4px; }
   .lede { color:#6b6b6b; max-width:640px; }
@@ -65,7 +77,9 @@
 <body>
 <header>
   <a class="mast" href="/">Celebrity Signatures</a>
-  <nav><a href="/">Gallery</a><a href="/murals">Murals</a><a href="/wear">Wear</a><a href="/game">Games</a></nav>
+  <nav><a href="/">Gallery</a><a href="/murals">Murals</a><a href="/wear">Wear</a><a href="/game">Games</a>
+    <button id="cartBtn" class="cart-btn" aria-label="Cart">🛍 Cart <span id="cartCount" class="cart-badge" hidden>0</span></button>
+  </nav>
 </header>
 
 <div class="wrap">
@@ -111,9 +125,84 @@
   </div>
 </div>
 
+<div class="scrim" id="cartScrim">
+  <span class="close" id="cartClose">×</span>
+  <div class="panel" style="grid-template-columns:1fr;max-width:480px">
+    <div class="buy">
+      <h2>Your cart</h2>
+      <div id="cartList"></div>
+      <div class="cart-total"><span>Total</span><span id="cartTotal">$0.00</span></div>
+      <div class="row"><input id="cartEmail" type="email" placeholder="your@email.com"></div>
+      <button class="btn" id="cartCheckout">Checkout</button>
+      <div class="msg" id="cartMsg"></div>
+      <div class="fine">This is a TEST checkout — no card is charged and no print order is submitted; each order is drafted for review.</div>
+    </div>
+  </div>
+</div>
+
 <script>
 const state = { data:null, sig:null, garment:null, color:null, size:null };
 
+// ---- cart (localStorage-backed; no login required) ----
+const CART_KEY = 'wear_cart';
+function loadCart(){ try { return JSON.parse(localStorage.getItem(CART_KEY) || '[]'); } catch { return []; } }
+function saveCart(cart){ try { localStorage.setItem(CART_KEY, JSON.stringify(cart)); } catch {} renderCartBadge(cart); }
+function renderCartBadge(cart){
+  cart = cart || loadCart();
+  const el = document.getElementById('cartCount');
+  el.textContent = cart.length; el.hidden = cart.length === 0;
+}
+function cartUnitPrice(garment, size){
+  const up = (garment.sizeUpchargeUsd && garment.sizeUpchargeUsd[size]) || 0;
+  return (garment.priceUsd || 0) + up;
+}
+function addToCart(item){
+  const cart = loadCart();
+  cart.push({ cid: Date.now()+'-'+Math.random().toString(36).slice(2,7), ...item });
+  saveCart(cart);
+}
+function removeFromCart(cid){
+  saveCart(loadCart().filter(i=>i.cid!==cid));
+  renderCart();
+}
+function renderCart(){
+  const cart = loadCart();
+  const list = document.getElementById('cartList');
+  if (!cart.length){
+    list.innerHTML = '<div class="cart-empty">Your cart is empty.</div>';
+    document.getElementById('cartTotal').textContent = '$0.00';
+    return;
+  }
+  list.innerHTML = cart.map(i=>`<div class="cart-item" data-cid="${i.cid}">
+    <img src="${i.thumb}" alt="">
+    <div class="ci-info"><div class="ci-name">${i.signature_name}</div><div class="ci-meta">${i.garment_label} · ${i.color_label} · ${i.size}</div></div>
+    <div class="ci-price">$${i.unitPriceUsd.toFixed(2)}</div>
+    <button class="ci-remove" data-cid="${i.cid}" title="Remove">×</button>
+  </div>`).join('');
+  list.querySelectorAll('.ci-remove').forEach(b=>b.onclick=()=>removeFromCart(b.dataset.cid));
+  const total = cart.reduce((s,i)=>s+i.unitPriceUsd,0);
+  document.getElementById('cartTotal').textContent = '$'+total.toFixed(2);
+}
+document.getElementById('cartBtn').onclick = ()=>{ renderCart(); document.getElementById('cartScrim').classList.add('on'); };
+document.getElementById('cartClose').onclick = ()=>document.getElementById('cartScrim').classList.remove('on');
+document.getElementById('cartScrim').onclick = e => { if(e.target.id==='cartScrim') document.getElementById('cartScrim').classList.remove('on'); };
+document.getElementById('cartCheckout').onclick = async ()=>{
+  const cart = loadCart();
+  const msg = document.getElementById('cartMsg'); msg.style.color='#c0392b';
+  if (!cart.length){ msg.textContent = 'Your cart is empty.'; return; }
+  const email = document.getElementById('cartEmail').value.trim();
+  if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.textContent='Enter a valid email.'; return; }
+  const btn = document.getElementById('cartCheckout'); btn.disabled=true; btn.textContent='Starting checkout…';
+  try{
+    const r = await fetch('/api/wear-checkout',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({ email, items: cart.map(i=>({ qid:i.qid, garment:i.garment, color:i.color, size:i.size })) })}).then(r=>r.json());
+    if(r.ok && r.url){ localStorage.removeItem(CART_KEY); location.href = r.url; return; }
+    if(r.comingSoon){ msg.style.color='#2e7d32'; msg.textContent = `You're on the list — we'll email ${email} the moment sales go live.`; btn.textContent='On the list ✓'; return; }
+    msg.textContent = r.error || 'Checkout unavailable.';
+  }catch(e){ msg.textContent='Network error.'; }
+  btn.disabled=false; btn.textContent='Checkout';
+};
+
 async function boot(){
   const r = await fetch('/api/wear/signatures').then(r=>r.json());
   state.data = r;
@@ -121,6 +210,7 @@ async function boot(){
   d.className = 'disclosure' + (r.disclosure.purchasable ? '' : ' warn');
   d.innerHTML = `<b>${(r.count||0).toLocaleString()} historic signatures</b> — public-domain figures (deceased on or before ${r.disclosure.cutoffDeathYear||1900}) on left-chest apparel.` +
     (r.disclosure.purchasable ? '' : ' <b>Sales open soon</b> — browse now; add your email to be notified.');
+  renderCartBadge();
   render();
 }
 
@@ -156,8 +246,9 @@ function openProduct(qid){
   document.getElementById('msg').textContent = '';
   const live = !!(state.data.disclosure && state.data.disclosure.purchasable);
   const bb = document.getElementById('buy'); bb.disabled=false; bb.textContent = live ? 'Add to cart' : 'Notify me when live';
-  const fine = document.querySelector('.fine'); if(fine) fine.textContent = live
-    ? 'Signature placed at the left chest. Fulfilled by our print partner.'
+  document.getElementById('email').closest('.row').style.display = live ? 'none' : '';
+  const fine = document.querySelector('#scrim .fine'); if(fine) fine.textContent = live
+    ? 'Signature placed at the left chest. Fulfilled by our print partner. Added items go to your cart — checkout when ready.'
     : 'Signature placed at the left chest. Apparel sales open soon — add your email and we’ll notify you the moment it goes live.';
   buildOptions();
   loadSigThenDraw();
@@ -262,18 +353,32 @@ document.getElementById('scrim').onclick = e => { if(e.target.id==='scrim') docu
 
 document.getElementById('buy').onclick = async ()=>{
   const live = !!(state.data && state.data.disclosure && state.data.disclosure.purchasable);
-  const email = document.getElementById('email').value.trim();
   const msg = document.getElementById('msg'); msg.style.color='#c0392b';
+  const btn = document.getElementById('buy');
+  if (live){
+    // Sales are live: add the current selection to the cart — no email needed
+    // yet, that's collected once at cart checkout, not per item.
+    addToCart({
+      qid: state.sig.qid, signature_name: state.sig.full_name,
+      garment: state.garment.id, garment_label: state.garment.label,
+      color: state.color.id, color_label: state.color.label, size: state.size,
+      unitPriceUsd: cartUnitPrice(state.garment, state.size), thumb: state.sig.signature_image_url,
+    });
+    msg.style.color='#2e7d32'; msg.textContent = 'Added to cart ✓';
+    btn.textContent = 'Added ✓'; setTimeout(()=>{ btn.textContent='Add to cart'; }, 1200);
+    return;
+  }
+  // Not live yet: keep the existing per-signature "notify me" email capture.
+  const email = document.getElementById('email').value.trim();
   if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.textContent='Enter a valid email.'; return; }
-  const btn = document.getElementById('buy'); btn.disabled=true; btn.textContent = live ? 'Starting checkout…' : 'Saving…';
+  btn.disabled=true; btn.textContent = 'Saving…';
   try{
     const r = await fetch('/api/wear-checkout',{method:'POST',headers:{'Content-Type':'application/json'},
       body:JSON.stringify({ qid:state.sig.qid, garment:state.garment.id, color:state.color.id, size:state.size, email })}).then(r=>r.json());
-    if(r.ok && r.url){ location.href = r.url; return; }
     if(r.comingSoon){ msg.style.color='#2e7d32'; msg.textContent = `You're on the list — we'll email ${email} the moment ${state.sig.full_name} apparel goes live.`; btn.textContent='On the list ✓'; return; }
     msg.textContent = r.error || 'Checkout unavailable.';
   }catch(e){ msg.textContent='Network error.'; }
-  btn.disabled=false; btn.textContent = live ? 'Add to cart' : 'Notify me when live';
+  btn.disabled=false; btn.textContent = 'Notify me when live';
 };
 
 // restore density pref
diff --git a/scripts/reconcile-wear-orders.mjs b/scripts/reconcile-wear-orders.mjs
index efb09f8..bdf211d 100644
--- a/scripts/reconcile-wear-orders.mjs
+++ b/scripts/reconcile-wear-orders.mjs
@@ -56,6 +56,15 @@ async function mergedSignatures() {
   const authors = await load('authors.json', []);
   return [...base, ...artists, ...authors];
 }
+// Mirrors server.js's wearOrderItems() — orders placed before the cart
+// feature (2026-09-08) store one item's fields directly on the order;
+// the cart shape stores an items[] array instead.
+function wearOrderItems(order) {
+  if (Array.isArray(order.items)) return order.items;
+  if (!order.qid) return [];
+  return [{ qid: order.qid, signature_name: order.signature_name, garment: order.garment, garment_label: order.garment_label,
+    color: order.color, color_label: order.color_label, size: order.size, placement: order.placement }];
+}
 
 async function main() {
   const key = resolveStripeKey();
@@ -83,7 +92,7 @@ async function main() {
     if (s.payment_status !== 'paid') { stillUnpaid++; continue; }
 
     newlyPaid++;
-    console.log(`  order #${order.id} (${order.signature_name}): Stripe reports PAID, but no draft was recorded — reconciling.`);
+    console.log(`  order #${order.id}: Stripe reports PAID, but no draft was recorded — reconciling.`);
     if (!APPLY) continue;
 
     order.status = 'paid'; order.paidAt = new Date().toISOString();
@@ -92,16 +101,22 @@ async function main() {
     order.recipient = { name: sd.name || null, address1: ad.line1 || null, address2: ad.line2 || null,
       city: ad.city || null, state_code: ad.state || null, country_code: ad.country || null, zip: ad.postal_code || null };
     const provider = order.provider || 'printful';
-    if (!order.pod_submitted && !alreadyDraftedIds.has(order.id)) {
+    if (!order.pod_submitted) {
       if (!sigCache) sigCache = await mergedSignatures();
-      const sig = sigCache.find(x => qidOf(x) === order.qid);
-      const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT', provider,
-        recipient_email: order.email, recipient: order.recipient, garment: order.garment, color: order.color, size: order.size,
-        placement: order.placement, signature_name: order.signature_name,
-        design_image_url: sig ? sig.signature_image_url : null,
-        note: `NOT SENT — awaiting Steve approval + ${provider} credentials/mapping + WEAR_SALES_LIVE=1 (drafted by reconcile-wear-orders.mjs — customer never reached /wear-success)` };
-      await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
-      alreadyDraftedIds.add(order.id);
+      const items = wearOrderItems(order);
+      for (let i = 0; i < items.length; i++) {
+        const it = items[i];
+        const compositeId = `${order.id}.${i}`;
+        if (alreadyDraftedIds.has(compositeId)) continue;
+        const sig = sigCache.find(x => qidOf(x) === it.qid);
+        const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
+          recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,
+          placement: it.placement, signature_name: it.signature_name,
+          design_image_url: sig ? sig.signature_image_url : null,
+          note: `NOT SENT — awaiting Steve approval + ${provider} credentials/mapping + WEAR_SALES_LIVE=1 (drafted by reconcile-wear-orders.mjs — customer never reached /wear-success)` };
+        await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
+        alreadyDraftedIds.add(compositeId);
+      }
     }
     order.pod_submitted = true;
     changed++;
diff --git a/server.js b/server.js
index 71ed5a4..b8e4cc8 100644
--- a/server.js
+++ b/server.js
@@ -206,6 +206,17 @@ const DEFAULT_PRICE_USD = 20;           // download price (payments wired later
 const DEFAULT_COMMISSION_PCT = 50;      // owner's cut per download
 const WEAR_GARMENT_DEFAULT_PRICE_USD = 34; // fallback garment price when template omits priceUsd
 const WEAR_ORDER_ID_SEED = 5000;        // wear orders start above 5000 to avoid colliding with mural/download IDs
+const WEAR_CART_MAX_ITEMS = 20;         // sane cap on a single checkout — not a real limit concern, just guards against abuse
+// Orders placed before the cart feature (2026-09-08) stored one item's fields
+// directly on the order (qid/garment/color/size/...); the cart shape stores
+// an items[] array instead. This normalizes either shape to items[] so
+// /wear-success and the reconciliation script don't need two code paths.
+function wearOrderItems(order) {
+  if (Array.isArray(order.items)) return order.items;
+  if (!order.qid) return [];
+  return [{ qid: order.qid, signature_name: order.signature_name, garment: order.garment, garment_label: order.garment_label,
+    color: order.color, color_label: order.color_label, size: order.size, placement: order.placement }];
+}
 async function adminToken() {
   // Bootstrap a local admin token on first use (data/admin-token.txt, gitignored
   // + deploy-protected). Steve reads it from the file to approve uploads.
@@ -804,11 +815,11 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
       }, { 'Cache-Control': 'no-cache' });
     }
 
-    // ===== /wear checkout (Stripe TEST mode only) =====
+    // ===== /wear checkout (Stripe TEST mode only) — a real multi-item cart =====
     // Mirrors the mural-checkout flow but uses the TEST download key — going live
     // (real cards) AND actually submitting to a POD firm are both Steve-gated. On
-    // payment, a POD-order draft is appended to data/pod-order-drafts.jsonl; NO POD
-    // API call is made here.
+    // payment, a POD-order draft is appended to data/pod-order-drafts.jsonl per
+    // cart line item; NO POD API call is made here.
     if (path === '/api/wear-checkout' && M === 'POST') {
       // v1 soft-launch: catalog is public + browsable, but real sales stay OFF until Steve
       // flips WEAR_SALES_LIVE=1 (paired with live Stripe + a wired POD). Until then, no visitor
@@ -827,25 +838,34 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
       const b = await readBody(req);
       const email = String(b.email || '').trim();
       if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'valid email required' });
+      const cartIn = Array.isArray(b.items) ? b.items : (b.qid ? [b] : []); // back-compat: a bare single item still works
+      if (!cartIn.length) return sendJSON(res, 400, { ok: false, error: 'cart is empty' });
+      if (cartIn.length > WEAR_CART_MAX_ITEMS) return sendJSON(res, 400, { ok: false, error: `cart is limited to ${WEAR_CART_MAX_ITEMS} items` });
       const clr = await tmClearance();
-      const sig = (await mergedSignatures()).find(s => qidOf(s) === String(b.qid || ''));
-      if (!sig || !wearEligible(sig, clr)) return sendJSON(res, 403, { ok: false, error: 'that signature is not cleared for sale' });
+      const allSigs = await mergedSignatures();
       const tpl = podTemplates;
-      const garment = (tpl.garments || []).find(g => g.id === String(b.garment || ''));
-      if (!garment) return sendJSON(res, 400, { ok: false, error: 'unknown garment' });
-      const color = (garment.colors || []).find(c => c.id === String(b.color || '')) || garment.colors[0];
-      if (!(garment.sizes || []).includes(String(b.size))) return sendJSON(res, 400, { ok: false, error: 'unknown size' });
-      const size = String(b.size);
-      // Base price + any size upcharge (e.g. 2XL costs Printful more; keep our margin flat).
-      const baseUsd = garment.priceUsd || WEAR_GARMENT_DEFAULT_PRICE_USD;
-      const upchargeUsd = (garment.sizeUpchargeUsd && garment.sizeUpchargeUsd[size]) || 0;
-      const amountCents = Math.round((baseUsd + upchargeUsd) * 100);
+      const resolvedItems = [];
+      for (const it of cartIn) {
+        const sig = allSigs.find(s => qidOf(s) === String(it.qid || ''));
+        if (!sig || !wearEligible(sig, clr)) return sendJSON(res, 403, { ok: false, error: `${it.qid || 'a signature'} is not cleared for sale` });
+        const garment = (tpl.garments || []).find(g => g.id === String(it.garment || ''));
+        if (!garment) return sendJSON(res, 400, { ok: false, error: 'unknown garment' });
+        const color = (garment.colors || []).find(c => c.id === String(it.color || '')) || garment.colors[0];
+        if (!(garment.sizes || []).includes(String(it.size))) return sendJSON(res, 400, { ok: false, error: 'unknown size' });
+        const size = String(it.size);
+        // Base price + any size upcharge (e.g. 2XL costs Printful more; keep our margin flat).
+        const baseUsd = garment.priceUsd || WEAR_GARMENT_DEFAULT_PRICE_USD;
+        const upchargeUsd = (garment.sizeUpchargeUsd && garment.sizeUpchargeUsd[size]) || 0;
+        const amountCents = Math.round((baseUsd + upchargeUsd) * 100);
+        resolvedItems.push({ qid: qidOf(sig), signature_name: sig.full_name, garment: garment.id, garment_label: garment.label,
+          color: color.id, color_label: color.label, size, placement: tpl.placement || 'left_chest', amountCents });
+      }
       const u = await currentUser(req);
       const list = await load('wear-orders.json', []);
       const id = (list.at(-1)?.id || WEAR_ORDER_ID_SEED) + 1;
+      const totalCents = resolvedItems.reduce((s, i) => s + i.amountCents, 0);
       const order = { id, at: new Date().toISOString(), account: u ? u.email : null, email,
-        qid: qidOf(sig), signature_name: sig.full_name, garment: garment.id, garment_label: garment.label,
-        color: color.id, size, placement: tpl.placement || 'left_chest', amountUsd: amountCents / 100,
+        items: resolvedItems, amountUsd: totalCents / 100,
         // Pin the POD provider to the order at the moment of purchase. If
         // WEAR_POD_PROVIDER (or its readiness) changes before the customer lands
         // on /wear-success, the draft must still route to the provider that was
@@ -860,11 +880,13 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
       params.set('cancel_url', 'https://celebsignatures.com/wear');
       params.set('customer_email', email);
       params.set('client_reference_id', String(id));
-      params.set('line_items[0][quantity]', '1');
-      params.set('line_items[0][price_data][currency]', 'usd');
-      params.set('line_items[0][price_data][unit_amount]', String(amountCents));
-      params.set('line_items[0][price_data][product_data][name]', `${sig.full_name} signature — left-chest ${garment.label} (${color.label}, ${size})`);
-      params.set('line_items[0][price_data][product_data][description]', `Signature printed at the left chest; made to order.${STRIPE_MODE === 'test' ? ' TEST checkout.' : ''}`);
+      resolvedItems.forEach((it, i) => {
+        params.set(`line_items[${i}][quantity]`, '1');
+        params.set(`line_items[${i}][price_data][currency]`, 'usd');
+        params.set(`line_items[${i}][price_data][unit_amount]`, String(it.amountCents));
+        params.set(`line_items[${i}][price_data][product_data][name]`, `${it.signature_name} signature — left-chest ${it.garment_label} (${it.color_label}, ${it.size})`);
+        params.set(`line_items[${i}][price_data][product_data][description]`, `Signature printed at the left chest; made to order.${STRIPE_MODE === 'test' ? ' TEST checkout.' : ''}`);
+      });
       params.set('metadata[order_id]', String(id));
       try {
         const sres = await fetch('https://api.stripe.com/v1/checkout/sessions', {
@@ -895,28 +917,34 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
               const ad = sd.address || {};
               order.recipient = { name: sd.name || null, address1: ad.line1 || null, address2: ad.line2 || null,
                 city: ad.city || null, state_code: ad.state || null, country_code: ad.country || null, zip: ad.postal_code || null };
-              // DRAFT the POD order — do NOT submit. The selected provider's
-              // separately gated sender validates and transmits it later. Use the
-              // provider PINNED on the order at checkout time (falls back to the
-              // live env for orders placed before that field existed) — never
-              // re-read WEAR_POD_PROVIDER fresh here, or a provider switch between
-              // checkout and this GET could route a paid order to a provider whose
-              // variant map doesn't match what the customer actually bought.
+              // DRAFT one POD order per cart line item — do NOT submit. The selected
+              // provider's separately gated sender validates and transmits each one
+              // later. Use the provider PINNED on the order at checkout time (falls
+              // back to the live env for orders placed before that field existed) —
+              // never re-read WEAR_POD_PROVIDER fresh here, or a provider switch
+              // between checkout and this GET could route a paid order to a
+              // provider whose variant map doesn't match what the customer bought.
               const provider = order.provider || wearPodProvider();
               if (!order.pod_submitted) {
-                // Idempotency guard: if a prior run appended the draft line but
-                // crashed before persisting pod_submitted=true, don't re-append —
-                // check the append-only log itself, not just the in-memory flag.
-                let alreadyDrafted = false;
-                try {
-                  const existingLines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean);
-                  alreadyDrafted = existingLines.some(l => { try { return JSON.parse(l).orderId === order.id; } catch { return false; } });
-                } catch {}
-                if (!alreadyDrafted) {
-                  const sig = (await mergedSignatures()).find(x => qidOf(x) === order.qid);
-                  const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT', provider,
-                    recipient_email: order.email, recipient: order.recipient, garment: order.garment, color: order.color, size: order.size,
-                    placement: order.placement, signature_name: order.signature_name,
+                // Idempotency guard: if a prior run appended some draft lines but
+                // crashed before persisting pod_submitted=true, don't re-append the
+                // ones already written — check the append-only log itself, not
+                // just the in-memory flag. Each cart line item gets its own
+                // composite id ("<orderId>.<itemIndex>") so a multi-item order
+                // produces one draft — and one eventual Printify order — per item.
+                let existingLines = [];
+                try { existingLines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
+                const alreadyDrafted = new Set(existingLines.map(l => { try { return JSON.parse(l).orderId; } catch { return null; } }));
+                const sigsAll = await mergedSignatures();
+                const items = wearOrderItems(order);
+                for (let i = 0; i < items.length; i++) {
+                  const it = items[i];
+                  const compositeId = `${order.id}.${i}`;
+                  if (alreadyDrafted.has(compositeId)) continue;
+                  const sig = sigsAll.find(x => qidOf(x) === it.qid);
+                  const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
+                    recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,
+                    placement: it.placement, signature_name: it.signature_name,
                     design_image_url: sig ? sig.signature_image_url : null,
                     note: `NOT SENT — awaiting Steve approval + ${provider} credentials/mapping + WEAR_SALES_LIVE=1` };
                   await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
@@ -935,7 +963,7 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
 .ok{font-size:52px}a.btn{display:inline-block;margin-top:22px;padding:12px 26px;border-radius:999px;background:#1a1a1a;color:#fff;text-decoration:none}
 .sub{color:#6b6b6b}</style></head><body><div class="w">
 ${paid ? `<div class="ok">✓</div><h1>Order received</h1>
-<p>Thanks${order ? ', ' + esc(order.signature_name) : ''} — your left-chest ${order ? esc(order.garment_label) : 'shirt'} is queued. (TEST order #${order ? order.id : ''}.)</p>
+<p>${order ? wearOrderItems(order).map(it => `${esc(it.signature_name)} — left-chest ${esc(it.garment_label)} (${esc(it.color_label || it.color)}, ${esc(it.size)})`).join('<br>') : 'Your order'} ${wearOrderItems(order || {}).length > 1 ? 'are' : 'is'} queued. (TEST order #${order ? order.id : ''}.)</p>
 <p class="sub">This is a test checkout; nothing was charged and no print order has been submitted yet.</p>`
 : `<h1>Order not completed</h1><p class="sub">No payment was captured.</p>`}
 <a class="btn" href="/wear">Back to the shop</a></div></body></html>`;

← adf1313 wear: polo left_chest placeholder, add cap garment, collar f  ·  back to CelebritySignatures  ·  wear: drop the cap, real Printify product photos instead of 108381c →