← 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
M public/wear.htmlM scripts/reconcile-wear-orders.mjsM server.js
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 →