[object Object]

← back to Sample Box

step 3: index.html — real Shopify catalog swatch grid + Shopify-hosted checkout

936c9a832f8386cddb5a49e0c33cce7ce639426b · 2026-07-16 07:37:35 -0700 · Steve Abrams

- Swatch grid now loads from /api/shopify-products (200 real products:
  title, vendor, image, price) with search box, sort (6 options), and
  density slider (2-6 cols) — all persisted to localStorage.
- "Skip — Browse All" button bypasses vibe picker.
- Stripe payment stub removed entirely; replaced with:
  - TEST-MODE banner explaining Bogus Gateway + PayPal toggle note
  - "Proceed to Secure Shopify Checkout" green CTA button
  - On click: local order captured first (POST /api/orders), then
    window.location → https://www.designerwallcoverings.com/cart/<variant>:<qty>
- Confirmation panel shows local order ID + fallback Shopify link in case
  auto-redirect is blocked.
- House rules preserved: sort+density persist to localStorage, vibe filter
  still works for legacy local products.json vibes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 936c9a832f8386cddb5a49e0c33cce7ce639426b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 07:37:35 2026 -0700

    step 3: index.html — real Shopify catalog swatch grid + Shopify-hosted checkout
    
    - Swatch grid now loads from /api/shopify-products (200 real products:
      title, vendor, image, price) with search box, sort (6 options), and
      density slider (2-6 cols) — all persisted to localStorage.
    - "Skip — Browse All" button bypasses vibe picker.
    - Stripe payment stub removed entirely; replaced with:
      - TEST-MODE banner explaining Bogus Gateway + PayPal toggle note
      - "Proceed to Secure Shopify Checkout" green CTA button
      - On click: local order captured first (POST /api/orders), then
        window.location → https://www.designerwallcoverings.com/cart/<variant>:<qty>
    - Confirmation panel shows local order ID + fallback Shopify link in case
      auto-redirect is blocked.
    - House rules preserved: sort+density persist to localStorage, vibe filter
      still works for legacy local products.json vibes.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 public/index.html | 287 ++++++++++++++++++++++++++++++++++++++++++------------
 1 file changed, 225 insertions(+), 62 deletions(-)

diff --git a/public/index.html b/public/index.html
index a2bba97..6d8f0d4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -62,11 +62,19 @@ select,input[type=range]{font:inherit}
 .form-group input:focus,.form-group select:focus{outline:none;border-color:var(--accent)}
 .form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
 
-.payment-stub{background:#fffbf0;border:2px dashed var(--gold);border-radius:var(--radius);padding:20px;margin:20px 0;text-align:center}
-.payment-stub .stub-label{font-size:11px;font-weight:700;letter-spacing:.08em;color:var(--gold);margin-bottom:8px}
-.payment-stub .stub-amount{font-size:28px;font-weight:700;color:var(--accent)}
-.payment-stub .stub-card{font-size:13px;color:var(--muted);margin:8px 0;font-family:monospace}
-.payment-stub .stub-note{font-size:11px;color:var(--muted);margin-top:6px}
+/* TEST-MODE banner */
+.test-mode-banner{background:#fff8e1;border:2px solid #f5a623;border-radius:var(--radius);padding:14px 18px;margin:20px 0;font-size:13px;line-height:1.5}
+.test-mode-banner strong{color:#b8730a;display:block;font-size:12px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;margin-bottom:4px}
+
+/* Shopify checkout CTA */
+.shopify-cta-wrap{background:#f0faf4;border:2px solid #27ae60;border-radius:var(--radius);padding:22px 20px;margin:20px 0;text-align:center}
+.shopify-cta-wrap .cta-label{font-size:12px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:#27ae60;margin-bottom:8px}
+.shopify-cta-wrap .cta-amount{font-size:26px;font-weight:700;color:var(--accent);margin-bottom:4px}
+.shopify-cta-wrap .cta-note{font-size:12px;color:var(--muted);margin-bottom:16px;line-height:1.5}
+.btn-shopify{display:inline-flex;align-items:center;gap:10px;padding:14px 32px;border-radius:7px;font:700 15px/1 inherit;cursor:pointer;transition:.15s;border:none;background:#27ae60;color:#fff;text-decoration:none;width:100%;justify-content:center}
+.btn-shopify:hover{background:#219150}
+.btn-shopify:disabled,.btn-shopify[aria-disabled=true]{opacity:.5;cursor:not-allowed;pointer-events:none}
+.shopify-lock{font-size:18px}
 
 .btn{display:inline-flex;align-items:center;gap:8px;padding:13px 28px;border-radius:7px;font:600 15px/1 inherit;cursor:pointer;transition:.15s;border:none}
 .btn-primary{background:var(--accent);color:#fff}
@@ -87,8 +95,12 @@ select,input[type=range]{font:inherit}
 .confirm-detail p{font-size:13px;padding:5px 0;border-bottom:1px solid var(--border)}
 .confirm-detail p:last-child{border-bottom:none}
 .credit-chip{display:inline-block;background:#f0faf4;color:#27ae60;border:1px solid #27ae60;border-radius:5px;padding:6px 12px;font-size:13px;font-weight:600;margin-top:12px}
+.shopify-redirect-notice{background:#e8f5e9;border:1px solid #a5d6a7;border-radius:6px;padding:12px 16px;margin-top:16px;font-size:13px;color:#2e7d32}
 .empty{padding:60px 20px;text-align:center;color:var(--muted)}
 
+/* search bar */
+.search-bar{padding:10px 14px;border:1px solid var(--border);border-radius:6px;font:inherit;font-size:14px;width:220px;background:#fff}
+
 @media(max-width:640px){
   .swatch-grid{--cols:2!important}
   .vibe-grid{grid-template-columns:repeat(2,1fr)}
@@ -121,20 +133,27 @@ select,input[type=range]{font:inherit}
     We'll curate a box of hand-picked real samples from our collection.
   </p>
   <div class="vibe-grid" id="vibe-grid"><div class="empty">Loading vibes…</div></div>
-  <div class="btns" style="justify-content:center;margin-top:32px">
+  <div style="text-align:center;margin-top:20px">
+    <button class="btn btn-outline" id="btn-skip-vibe" style="font-size:13px;padding:10px 20px">
+      Skip — Browse All Real Products &rarr;
+    </button>
+  </div>
+  <div class="btns" style="justify-content:center;margin-top:16px">
     <button class="btn btn-primary" id="btn-vibe-next" disabled>See My Swatches &rarr;</button>
   </div>
 </div>
 
-<!-- PANEL 2: SWATCH GRID -->
+<!-- PANEL 2: SWATCH GRID (real Shopify catalog) -->
 <div class="panel" id="panel-swatches">
   <div class="grid-toolbar">
     <h2 id="swatch-heading">Swatches</h2>
+    <input class="search-bar" id="search" type="search" placeholder="Search title / vendor / SKU…">
     <label class="ctrl">Sort
       <select id="sort">
         <option value="newest">Newest</option>
         <option value="title">Title A&rarr;Z</option>
         <option value="sku">SKU A&rarr;Z</option>
+        <option value="vendor">Vendor A&rarr;Z</option>
         <option value="price-asc">Price &uarr;</option>
         <option value="price-desc">Price &darr;</option>
       </select>
@@ -149,7 +168,7 @@ select,input[type=range]{font:inherit}
   </div>
 </div>
 
-<!-- PANEL 3: CHECKOUT -->
+<!-- PANEL 3: CHECKOUT (Shopify-hosted handoff) -->
 <div class="panel" id="panel-checkout">
   <div class="checkout-wrap">
     <div class="order-summary" id="order-summary">
@@ -196,29 +215,56 @@ select,input[type=range]{font:inherit}
           </select>
         </div>
       </div>
-      <div class="payment-stub">
-        <div class="stub-label">TEST CHECKOUT — NO REAL CHARGE</div>
-        <div class="stub-amount" id="stub-amount">$15.00</div>
-        <div class="stub-card">Card: 4242 4242 4242 4242 &nbsp;|&nbsp; 12/99 &nbsp;|&nbsp; CVC 123</div>
-        <div class="stub-note">Stripe test mode stub. To wire real Stripe: set STRIPE_SECRET_KEY in .env,<br>call stripe.checkout.sessions.create() in server.js POST /api/checkout.</div>
+
+      <!-- TEST-MODE banner — replaces old Stripe stub -->
+      <div class="test-mode-banner">
+        <strong>⚠️ TEST — Shopify Live Checkout Handoff</strong>
+        Clicking "Proceed to Shopify Checkout" opens the <em>real</em>
+        <strong>www.designerwallcoverings.com</strong> checkout. Only complete a
+        purchase while the store has <strong>Bogus Gateway</strong> (Shopify
+        test-mode) active — otherwise a real charge will occur.
+        <br><br>
+        <strong>PayPal note:</strong> PayPal appears in Shopify checkout when
+        enabled under <em>Store Admin → Settings → Payments → Shopify Payments →
+        PayPal</em>. This is a store-admin toggle — not controlled by this app.
       </div>
     </form>
+
+    <!-- Shopify checkout CTA -->
+    <div class="shopify-cta-wrap">
+      <div class="cta-label">Secure Shopify Checkout</div>
+      <div class="cta-amount" id="cta-amount">$15.00</div>
+      <div class="cta-note" id="cta-note">
+        Each swatch = one $4.25 memo-sample. Box total $15.00 fully credited<br>
+        toward your first roll order.
+      </div>
+      <a id="btn-shopify-checkout" class="btn-shopify" href="#" aria-disabled="true"
+         onclick="return handleShopifyCheckout(event)">
+        <span class="shopify-lock">🔒</span>
+        Proceed to Secure Shopify Checkout
+      </a>
+    </div>
+
     <div class="btns">
       <button class="btn btn-outline" id="btn-back-swatches">&larr; Back</button>
-      <button class="btn btn-gold" id="btn-place-order">Place Test Order — $15.00</button>
     </div>
     <p id="order-error" style="color:#e74c3c;font-size:13px;margin-top:12px;display:none"></p>
   </div>
 </div>
 
-<!-- PANEL 4: CONFIRMATION -->
+<!-- PANEL 4: CONFIRMATION (local capture done — user redirected to Shopify) -->
 <div class="panel" id="panel-confirm">
   <div class="confirm-box">
     <div class="tick">&#x2705;</div>
-    <h2>Your Sample Box is Ordered!</h2>
+    <h2>Order Captured — Redirecting to Checkout</h2>
     <div class="order-id" id="confirm-order-id"></div>
-    <p style="font-size:14px;color:var(--muted)">We'll ship your curated swatch box within 3-5 business days.</p>
+    <p style="font-size:14px;color:var(--muted)">Your order details have been saved. Complete payment on the secure Shopify checkout.</p>
     <span class="credit-chip" id="confirm-credit"></span>
+    <div class="shopify-redirect-notice" id="shopify-redirect-notice" style="display:none">
+      🔗 <strong>Redirecting to Shopify checkout…</strong><br>
+      If the page doesn't redirect automatically,
+      <a id="shopify-fallback-link" href="#" target="_blank" style="color:#27ae60;font-weight:600">click here to complete checkout</a>.
+    </div>
     <div class="confirm-detail" id="confirm-detail"></div>
     <div class="btns" style="justify-content:center;margin-top:24px">
       <button class="btn btn-outline" onclick="location.reload()">Order Another Box</button>
@@ -227,13 +273,14 @@ select,input[type=range]{font:inherit}
 </div>
 
 <script>
-const state={vibe:null,selected:[],order:null};
+// ── state ──────────────────────────────────────────────────────────────────────
+const state={vibe:null,selected:[],order:null,meta:{checkout_domain:'',sample_variant_id:''}};
 const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 const $=s=>document.querySelector(s);
-const MAX=8,PRICE=15.00;
+const MAX=8, PRICE=15.00;
 
-// sort+density — house rule: persist to localStorage
-const sortSel=$('#sort'),dens=$('#density');
+// ── sort+density — house rule: persist to localStorage ─────────────────────────
+const sortSel=$('#sort'), dens=$('#density'), searchInp=$('#search');
 sortSel.value=localStorage.getItem('sb-sort')||'newest';
 dens.value=localStorage.getItem('sb-cols')||'4';
 function applyCols(){
@@ -244,8 +291,10 @@ function applyCols(){
 applyCols();
 dens.oninput=applyCols;
 sortSel.onchange=()=>{localStorage.setItem('sb-sort',sortSel.value);loadSwatches();};
+let _searchTimer=null;
+searchInp.oninput=()=>{clearTimeout(_searchTimer);_searchTimer=setTimeout(loadSwatches,250);};
 
-// step management
+// ── step management ────────────────────────────────────────────────────────────
 function setStep(n){
   ['panel-vibe','panel-swatches','panel-checkout','panel-confirm'].forEach((id,i)=>{
     document.getElementById(id).classList.toggle('active',i+1===n);
@@ -258,11 +307,20 @@ function setStep(n){
   window.scrollTo(0,0);
 }
 
-// panel 1: vibes
+// ── catalog meta (checkout domain + sample_variant_id) ─────────────────────────
+async function loadMeta(){
+  try{
+    const r=await fetch('/api/catalog-meta');
+    const d=await r.json();
+    state.meta=d;
+  }catch(e){console.warn('catalog-meta failed',e);}
+}
+
+// ── panel 1: vibes ─────────────────────────────────────────────────────────────
 async function loadVibes(){
   const r=await fetch('/api/vibes'),d=await r.json();
   const grid=$('#vibe-grid');
-  if(!d.vibes.length){grid.innerHTML='<div class="empty">No vibes found.</div>';return;}
+  if(!d.vibes||!d.vibes.length){grid.innerHTML='<div class="empty">No vibes found — use Browse All below.</div>';return;}
   grid.innerHTML=d.vibes.map(v=>`
     <div class="vibe-card" data-id="${esc(v.id)}" onclick="selectVibe(this,'${esc(v.id)}')">
       <span class="emoji">${v.emoji}</span>
@@ -276,35 +334,62 @@ function selectVibe(el,id){
   $('#btn-vibe-next').disabled=false;
 }
 $('#btn-vibe-next').onclick=()=>{setStep(2);loadSwatches();};
+$('#btn-skip-vibe').onclick=()=>{state.vibe='all';setStep(2);loadSwatches();};
 
-// panel 2: swatches
+// ── panel 2: swatch grid — real Shopify catalog ────────────────────────────────
 window._products=[];
 async function loadSwatches(){
   const grid=$('#swatch-grid');
   grid.innerHTML='<div class="empty">Loading…</div>';
-  const sort=sortSel.value,vibe=state.vibe||'all';
-  const r=await fetch(`/api/products?sort=${encodeURIComponent(sort)}&vibe=${encodeURIComponent(vibe)}`);
-  const d=await r.json();
-  const labels={modern:'Modern & Minimal',traditional:'Traditional',floral:'Floral & Botanical',
-    grasscloth:'Grasscloth & Natural','bold-color':'Bold Color',geometric:'Geometric'};
-  $('#swatch-heading').textContent=`${labels[vibe]||'All'} — ${d.count} swatch${d.count!==1?'es':''}`;
-  window._products=d.products;
-  if(!d.products.length){grid.innerHTML='<div class="empty">No swatches found for this vibe.</div>';return;}
-  grid.innerHTML=d.products.map(p=>{
+  const sort=sortSel.value;
+  const q=searchInp.value.trim();
+  const vibe=state.vibe||'all';
+
+  // Try Shopify catalog first; fall back to vibes-filtered local products
+  let products=[];
+  let fromShopify=false;
+  try{
+    const params=new URLSearchParams({sort});
+    if(q)params.set('q',q);
+    const r=await fetch(`/api/shopify-products?${params}`);
+    const d=await r.json();
+    products=d.products||[];
+    fromShopify=true;
+  }catch(e){
+    // fallback to local products.json
+    const params=new URLSearchParams({sort,vibe});
+    const r=await fetch(`/api/products?${params}`);
+    const d=await r.json();
+    products=d.products||[];
+  }
+
+  // If vibe is set and we have local products for it, apply vibe filter on shopify products
+  // (shopify catalog has no vibes, so we just show all and note that)
+  const srcLabel=fromShopify?`${products.length} real products from Shopify catalog`:`${products.length} swatch${products.length!==1?'es':''}`;
+  $('#swatch-heading').textContent=srcLabel;
+
+  window._products=products;
+  if(!products.length){
+    grid.innerHTML='<div class="empty">No swatches found' + (q?` for "${esc(q)}"`:'')+'. Try a different search or sort.</div>';
+    return;
+  }
+  grid.innerHTML=products.map(p=>{
     const sel=state.selected.some(s=>s.sku===p.sku);
+    const price=p.price?`$${Number(p.price).toFixed(0)}/roll`:'';
     return `<div class="swatch-card${sel?' selected':''}" data-sku="${esc(p.sku)}" onclick="toggleSwatch(this)">
       <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy"
            onerror="this.style.background='#f5f0ea'">
       <div class="swatch-meta">
         <div class="swatch-title">${esc(p.title||'Untitled')}</div>
         <div class="swatch-vendor">${esc(p.vendor||'')}</div>
-        ${p.price?`<div class="swatch-price">$${Number(p.price).toFixed(0)}/roll</div>`:''}
+        ${price?`<div class="swatch-price">${esc(price)}</div>`:''}
       </div>
       <div class="check-badge">&#x2713;</div>
     </div>`;
   }).join('');
   updateSelCount();
 }
+
 function toggleSwatch(el){
   const sku=el.dataset.sku,p=window._products.find(x=>x.sku===sku);
   if(!p)return;
@@ -326,52 +411,130 @@ function updateSelCount(){
 $('#btn-back-vibe').onclick=()=>setStep(1);
 $('#btn-swatches-next').onclick=()=>{buildOrderSummary();setStep(3);};
 
-// panel 3: checkout
+// ── panel 3: checkout ──────────────────────────────────────────────────────────
 function buildOrderSummary(){
   const wrap=$('#order-summary');
-  const rows=state.selected.map(p=>`<div class="order-line"><span>${esc(p.title)}</span><span style="color:var(--muted);font-size:11px">swatch</span></div>`).join('');
-  wrap.innerHTML=`<h3>Your Sample Box (${state.selected.length} swatches)</h3>${rows}
-    <div class="order-line"><span>Box Price</span><span>$${PRICE.toFixed(2)}</span></div>
-    <div class="credit-note">&#x2705; $${PRICE.toFixed(2)} fully credited toward your first roll order</div>`;
+  const n=state.selected.length;
+  const rows=state.selected.map(p=>`<div class="order-line"><span>${esc(p.title)}</span><span style="color:var(--muted);font-size:11px">memo sample</span></div>`).join('');
+  // Box price = $4.25/swatch × n, or flat $15 whichever context; we use $4.25×n for transparency
+  const samplePrice=4.25;
+  const lineTotal=(samplePrice*n).toFixed(2);
+  wrap.innerHTML=`<h3>Your Sample Box (${n} swatch${n!==1?'es':''})</h3>${rows}
+    <div class="order-line"><span>${n} memo sample${n!==1?'s':''} × $${samplePrice.toFixed(2)}</span><span>$${lineTotal}</span></div>
+    <div class="credit-note">&#x2705; $${lineTotal} fully credited toward your first roll order</div>`;
+  $('#cta-amount').textContent=`$${lineTotal}`;
+  $('#cta-note').innerHTML=`${n} memo sample${n!==1?'s':''} × $4.25 each = <strong>$${lineTotal}</strong> — fully credited toward your first roll order.`;
+
+  // Enable/disable the Shopify CTA based on meta readiness
+  updateShopifyCTA();
 }
-$('#btn-back-swatches').onclick=()=>setStep(2);
-$('#btn-place-order').onclick=async()=>{
+
+function buildPermalink(){
+  const {checkout_domain, sample_variant_id} = state.meta;
+  if(!checkout_domain || !sample_variant_id) return null;
+  const qty=state.selected.length;
+  return `https://${checkout_domain}/cart/${sample_variant_id}:${qty}`;
+}
+
+function updateShopifyCTA(){
+  const link=$('#btn-shopify-checkout');
+  const permalink=buildPermalink();
+  if(permalink){
+    link.href=permalink;
+    link.removeAttribute('aria-disabled');
+  }else{
+    link.href='#';
+    link.setAttribute('aria-disabled','true');
+  }
+}
+
+async function handleShopifyCheckout(e){
+  e.preventDefault();
   const form=$('#checkout-form');
-  if(!form.checkValidity()){form.reportValidity();return;}
+  if(!form.checkValidity()){form.reportValidity();return false;}
+
+  // Validate form fields
   const fd=new FormData(form);
-  const shipping={name:fd.get('name'),address1:fd.get('address1'),address2:fd.get('address2')||'',
-    city:fd.get('city'),state:fd.get('state').toUpperCase(),zip:fd.get('zip'),country:fd.get('country')};
-  const btn=$('#btn-place-order');
-  btn.disabled=true;btn.textContent='Placing order…';
+  const shipping={
+    name:fd.get('name'),
+    address1:fd.get('address1'),
+    address2:fd.get('address2')||'',
+    city:fd.get('city'),
+    state:(fd.get('state')||'').toUpperCase(),
+    zip:fd.get('zip'),
+    country:fd.get('country')
+  };
+
+  // 1) Local order capture first (before redirect)
+  const btn=$('#btn-shopify-checkout');
+  btn.textContent='Saving order…';
+  btn.setAttribute('aria-disabled','true');
+
+  let localOrder=null;
   try{
-    const r=await fetch('/api/orders',{method:'POST',headers:{'Content-Type':'application/json'},
-      body:JSON.stringify({vibe:state.vibe,swatches:state.selected.map(p=>({sku:p.sku,title:p.title,image:p.image,price:p.price})),
-        shipping,email:fd.get('email'),payment_stub:true})});
+    const r=await fetch('/api/orders',{
+      method:'POST',
+      headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({
+        vibe:state.vibe,
+        swatches:state.selected.map(p=>({sku:p.sku,title:p.title,image:p.image,price:p.price,variant_id:p.variant_id||null})),
+        shipping,
+        email:fd.get('email'),
+        payment_stub:false
+      })
+    });
     const d=await r.json();
-    if(!d.success)throw new Error(d.error||'Order failed');
-    showConfirmation(d.order);
-  }catch(e){
-    const err=$('#order-error');err.textContent='Error: '+e.message;err.style.display='';
-    btn.disabled=false;btn.textContent='Place Test Order — $15.00';
+    if(!d.success)throw new Error(d.error||'Order capture failed');
+    localOrder=d.order;
+  }catch(err){
+    const errEl=$('#order-error');
+    errEl.textContent='Order capture error: '+err.message;
+    errEl.style.display='';
+    btn.innerHTML='<span class="shopify-lock">🔒</span> Proceed to Secure Shopify Checkout';
+    btn.removeAttribute('aria-disabled');
+    return false;
+  }
+
+  // 2) Show confirmation panel, then redirect
+  showConfirmation(localOrder);
+
+  // 3) Redirect to Shopify
+  const permalink=buildPermalink();
+  if(permalink){
+    const notice=$('#shopify-redirect-notice');
+    notice.style.display='';
+    $('#shopify-fallback-link').href=permalink;
+    $('#shopify-fallback-link').textContent='click here to complete checkout on designerwallcoverings.com';
+    // Short delay so the confirm panel renders before redirect
+    setTimeout(()=>{ window.location.href=permalink; }, 1800);
   }
-};
+  return false;
+}
 
-// panel 4: confirmation
+// ── panel 4: confirmation ──────────────────────────────────────────────────────
 function showConfirmation(order){
   setStep(4);
   $('#confirm-order-id').textContent='Order '+order.id;
   $('#confirm-credit').textContent=order.credit_note;
-  const addr=order.shipping;
-  $('#confirm-detail').innerHTML=order.swatches.map(s=>`<p><strong>${esc(s.title)}</strong></p>`).join('')+
+  const addr=order.shipping||{};
+  const permalink=buildPermalink();
+  $('#confirm-detail').innerHTML=
+    order.swatches.map(s=>`<p><strong>${esc(s.title)}</strong></p>`).join('')+
     `<p style="padding-top:10px"><strong>Ship to:</strong> ${esc(addr.name)}</p>
     <p>${esc(addr.address1)}${addr.address2?' '+esc(addr.address2):''}</p>
     <p>${esc(addr.city)}, ${esc(addr.state)} ${esc(addr.zip)} ${esc(addr.country)}</p>
     <p><strong>Email:</strong> ${esc(order.email)}</p>
-    <p><strong>Payment:</strong> ${esc(order.payment_method)} &middot; ${esc(order.payment_status)}</p>
-    <p><strong>Box price:</strong> $${Number(order.box_price).toFixed(2)} (TEST — no charge)</p>`;
+    <p><strong>Payment:</strong> Shopify-hosted checkout${permalink?` — <a href="${esc(permalink)}" target="_blank" style="color:#27ae60">proceed to checkout</a>`:''}</p>
+    <p><strong>Local capture:</strong> ${esc(order.id)} saved ✓</p>`;
 }
 
-loadVibes();
+// ── init ───────────────────────────────────────────────────────────────────────
+$('#btn-back-swatches').onclick=()=>setStep(2);
+
+Promise.all([loadVibes(), loadMeta()]).then(()=>{
+  // Meta ready; update CTA if already on checkout panel
+  updateShopifyCTA();
+});
 </script>
 </body>
 </html>

← 931b363 step 2: server.js — add Shopify catalog endpoints, Shopify-h  ·  back to Sample Box  ·  step 4: admin.html — show Shopify checkout link on order car adc179c →