added on local at 2026-07-01 13:48:01
| 1 | <!DOCTYPE html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="UTF-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | <title>Checkout · $store_name</title> | |
| 7 | <link rel="stylesheet" href="/assets/css/site.css"> | |
| 8 | <style>:root { $theme_css_vars }</style> | |
| 9 | <style> | |
| 10 | body { background: var(--col-bg); color: var(--col-text); font-family: var(--font-ui, sans-serif); margin: 0; } | |
| 11 | .co-page { max-width: 720px; margin: 0 auto; padding: 60px 24px 100px; } | |
| 12 | .co-card { | |
| 13 | background: var(--col-surface-1); | |
| 14 | border: 1px solid var(--col-border-2); | |
| 15 | border-radius: 16px; | |
| 16 | padding: 32px; | |
| 17 | } | |
| 18 | .co-card h1 { margin: 0 0 14px; font-size: 26px; color: var(--col-text); } | |
| 19 | .co-card p { color: var(--col-text-2); line-height: 1.7; } | |
| 20 | .co-line { display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--col-border); font-size:14px; } | |
| 21 | .co-line:last-child { border-bottom:0; } | |
| 22 | .co-line .qty { color:var(--col-text-3); font-size:12px; margin-left:6px; } | |
| 23 | .co-total-row { display:flex; justify-content:space-between; margin-top:16px; padding-top:14px; border-top:2px solid var(--col-border-2); font-weight:700; font-size:18px; color:var(--col-text); } | |
| 24 | .co-field { margin-top:18px; } | |
| 25 | .co-field label { display:block; font-size:12px; font-weight:600; color:var(--col-text-2); margin-bottom:6px; letter-spacing:0.4px; } | |
| 26 | .co-field input { width:100%; box-sizing:border-box; padding:11px 13px; border-radius:9px; border:1px solid var(--col-border); background:var(--col-bg-2); color:var(--col-text); font-size:14px; } | |
| 27 | #payment-element { margin-top:8px; padding:14px; background:var(--col-bg-2); border:1px solid var(--col-border); border-radius:9px; min-height:60px; } | |
| 28 | .co-btn { | |
| 29 | display:inline-block; padding:12px 22px; border-radius:10px; | |
| 30 | background: linear-gradient(130deg, var(--col-accent), var(--col-accent-bright)); | |
| 31 | border:0; color:#fff; font-size:14px; font-weight:700; cursor:pointer; | |
| 32 | width:100%; margin-top:20px; | |
| 33 | } | |
| 34 | .co-btn[disabled] { opacity:0.55; cursor:not-allowed; } | |
| 35 | .co-secondary { | |
| 36 | display:inline-block; padding:10px 18px; border-radius:9px; | |
| 37 | background: var(--col-surface-2); border:1px solid var(--col-border); | |
| 38 | color: var(--col-text); text-decoration:none; | |
| 39 | font-size: 13px; font-weight: 600; | |
| 40 | } | |
| 41 | .co-err { margin-top:12px; padding:10px 12px; border-radius:8px; background:rgba(239,68,68,0.10); border:1px solid rgba(239,68,68,0.35); color:#fecaca; font-size:13px; display:none; } | |
| 42 | .co-err.show { display:block; } | |
| 43 | .co-coming { | |
| 44 | font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; | |
| 45 | color: var(--col-accent-bright); | |
| 46 | margin-bottom: 12px; font-weight: 700; | |
| 47 | } | |
| 48 | .co-foot { margin-top:18px; text-align:center; font-size:12px; color:var(--col-text-3); } | |
| 49 | .co-foot svg { width:14px; height:14px; vertical-align:-2px; margin-right:4px; } | |
| 50 | </style> | |
| 51 | </head> | |
| 52 | <body> | |
| 53 | $store_chrome_html | |
| 54 | <main class="co-page"> | |
| 55 | ||
| 56 | [if:$has_items] | |
| 57 | <div class="co-card"> | |
| 58 | ||
| 59 | [if:$is_payable] | |
| 60 | <h1>Checkout</h1> | |
| 61 | <p style="margin-top:0">You're buying $item_count item(s) from <strong>$store_name</strong>. Files unlock the moment payment clears.</p> | |
| 62 | ||
| 63 | <div style="margin-top:20px"> | |
| 64 | [loop:@items] | |
| 65 | <div class="co-line"><span>$loop1.title<span class="qty">x$loop1.qty</span></span><span class="font-mono">$loop1.line_total</span></div> | |
| 66 | [/loop] | |
| 67 | <div class="co-line"><span>Subtotal</span><span class="font-mono" id="co-subtotal">$total_display</span></div> | |
| 68 | [if:$needs_shipping] | |
| 69 | <div class="co-line" id="co-ship-row"><span>Shipping</span><span class="font-mono" id="co-ship">\$0.00</span></div> | |
| 70 | [/if] | |
| 71 | <div class="co-line" id="co-tax-row" style="display:none"><span id="co-tax-label">Tax</span><span class="font-mono" id="co-tax">\$0.00</span></div> | |
| 72 | <div class="co-total-row"><span>Total</span><span class="font-mono" id="co-total">$total_display</span></div> | |
| 73 | </div> | |
| 74 | ||
| 75 | <form id="payment-form"> | |
| 76 | <div class="co-field"> | |
| 77 | <label for="co-email">Email for receipt and downloads</label> | |
| 78 | <input type="email" id="co-email" name="email" required value="$prefill_email" autocomplete="email"> | |
| 79 | </div> | |
| 80 | <div class="co-field"> | |
| 81 | <label for="co-bill-country">Billing country (for tax)</label> | |
| 82 | <select id="co-bill-country" name="bill_country" autocomplete="country"> | |
| 83 | <option value="">— Select country —</option> | |
| 84 | <option value="US">United States</option> | |
| 85 | <option value="CA">Canada</option> | |
| 86 | <option value="GB">United Kingdom</option> | |
| 87 | <option value="AT">Austria</option><option value="BE">Belgium</option><option value="BG">Bulgaria</option> | |
| 88 | <option value="HR">Croatia</option><option value="CY">Cyprus</option><option value="CZ">Czechia</option> | |
| 89 | <option value="DK">Denmark</option><option value="EE">Estonia</option><option value="FI">Finland</option> | |
| 90 | <option value="FR">France</option><option value="DE">Germany</option><option value="GR">Greece</option> | |
| 91 | <option value="HU">Hungary</option><option value="IE">Ireland</option><option value="IT">Italy</option> | |
| 92 | <option value="LV">Latvia</option><option value="LT">Lithuania</option><option value="LU">Luxembourg</option> | |
| 93 | <option value="MT">Malta</option><option value="NL">Netherlands</option><option value="PL">Poland</option> | |
| 94 | <option value="PT">Portugal</option><option value="RO">Romania</option><option value="SK">Slovakia</option> | |
| 95 | <option value="SI">Slovenia</option><option value="ES">Spain</option><option value="SE">Sweden</option> | |
| 96 | <option value="AU">Australia</option><option value="NZ">New Zealand</option> | |
| 97 | <option value="NO">Norway</option><option value="CH">Switzerland</option> | |
| 98 | <option value="OTHER">Other / Not listed (no tax)</option> | |
| 99 | </select> | |
| 100 | </div> | |
| 101 | <div class="co-field" id="co-region-wrap" style="display:none"> | |
| 102 | <label for="co-bill-region" id="co-region-label">State / Region</label> | |
| 103 | <input type="text" id="co-bill-region" name="bill_region" maxlength="10" placeholder="CA" autocomplete="address-level1"> | |
| 104 | </div> | |
| 105 | <div class="co-field" id="co-vat-wrap" style="display:none"> | |
| 106 | <label for="co-vat-id">VAT number (optional, B2B reverse charge)</label> | |
| 107 | <input type="text" id="co-vat-id" name="vat_id" maxlength="20" placeholder="DE123456789"> | |
| 108 | <div id="co-vat-status" style="margin-top:6px;font-size:12px;color:var(--col-text-3)"></div> | |
| 109 | </div> | |
| 110 | ||
| 111 | <!-- Billing address (for the credit card). Always shown so we | |
| 112 | can attach a full billing snapshot to the order even when | |
| 113 | there's no physical line to ship. Pre-fills from the | |
| 114 | billing country + region above; buyer fills in the rest. --> | |
| 115 | <div style="margin-top:22px;padding:16px 18px;border:1px solid var(--col-border);border-radius:12px;background:var(--col-surface-1)"> | |
| 116 | <div style="font-size:13px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-2);margin-bottom:12px">Billing address</div> | |
| 117 | <div class="co-field"> | |
| 118 | <label for="co-bill-name">Cardholder name</label> | |
| 119 | <input type="text" id="co-bill-name" name="bill_name" required autocomplete="cc-name" maxlength="120"> | |
| 120 | </div> | |
| 121 | <div class="co-field"> | |
| 122 | <label for="co-bill-addr1">Street address</label> | |
| 123 | <input type="text" id="co-bill-addr1" name="bill_addr1" required autocomplete="billing address-line1" maxlength="200"> | |
| 124 | </div> | |
| 125 | <div class="co-field"> | |
| 126 | <label for="co-bill-addr2">Apt / Suite <span style="color:var(--col-text-3);font-weight:400">(optional)</span></label> | |
| 127 | <input type="text" id="co-bill-addr2" name="bill_addr2" autocomplete="billing address-line2" maxlength="200"> | |
| 128 | </div> | |
| 129 | <div style="display:grid;grid-template-columns:2fr 1fr;gap:10px"> | |
| 130 | <div class="co-field"> | |
| 131 | <label for="co-bill-city">City</label> | |
| 132 | <input type="text" id="co-bill-city" name="bill_city" required autocomplete="billing address-level2" maxlength="120"> | |
| 133 | </div> | |
| 134 | <div class="co-field"> | |
| 135 | <label for="co-bill-postal">Postal code</label> | |
| 136 | <input type="text" id="co-bill-postal" name="bill_postal" required autocomplete="billing postal-code" maxlength="20"> | |
| 137 | </div> | |
| 138 | </div> | |
| 139 | <div class="co-field"> | |
| 140 | <label for="co-bill-phone">Phone <span style="color:var(--col-text-3);font-weight:400">(optional)</span></label> | |
| 141 | <input type="tel" id="co-bill-phone" name="bill_phone" autocomplete="billing tel" maxlength="40"> | |
| 142 | </div> | |
| 143 | </div> | |
| 144 | ||
| 145 | [if:$needs_shipping] | |
| 146 | <!-- Shipping address + rate selector. Only rendered when at | |
| 147 | least one cart line is a 3D-printed physical item. --> | |
| 148 | <div style="margin-top:22px;padding:16px 18px;border:1px solid var(--col-border);border-radius:12px;background:var(--col-surface-1)"> | |
| 149 | <div style="font-size:13px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-2);margin-bottom:12px">Shipping address</div> | |
| 150 | <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--col-text-2);margin-bottom:14px;cursor:pointer"> | |
| 151 | <input type="checkbox" id="co-same-as-billing" checked> | |
| 152 | <span>Same as billing</span> | |
| 153 | </label> | |
| 154 | ||
| 155 | <div class="co-field"> | |
| 156 | <label for="co-ship-name">Full name</label> | |
| 157 | <input type="text" id="co-ship-name" name="ship_name" required autocomplete="name" maxlength="120"> | |
| 158 | </div> | |
| 159 | <div class="co-field"> | |
| 160 | <label for="co-ship-addr1">Street address</label> | |
| 161 | <input type="text" id="co-ship-addr1" name="ship_addr1" required autocomplete="address-line1" maxlength="200"> | |
| 162 | </div> | |
| 163 | <div class="co-field"> | |
| 164 | <label for="co-ship-addr2">Apt / Suite / Unit <span style="color:var(--col-text-3);font-weight:400">(optional)</span></label> | |
| 165 | <input type="text" id="co-ship-addr2" name="ship_addr2" autocomplete="address-line2" maxlength="200"> | |
| 166 | </div> | |
| 167 | <div style="display:grid;grid-template-columns:2fr 1fr;gap:10px"> | |
| 168 | <div class="co-field"> | |
| 169 | <label for="co-ship-city">City</label> | |
| 170 | <input type="text" id="co-ship-city" name="ship_city" required autocomplete="address-level2" maxlength="120"> | |
| 171 | </div> | |
| 172 | <div class="co-field"> | |
| 173 | <label for="co-ship-region">State / Region</label> | |
| 174 | <input type="text" id="co-ship-region" name="ship_region" autocomplete="address-level1" maxlength="80"> | |
| 175 | </div> | |
| 176 | </div> | |
| 177 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"> | |
| 178 | <div class="co-field"> | |
| 179 | <label for="co-ship-postal">Postal code</label> | |
| 180 | <input type="text" id="co-ship-postal" name="ship_postal" required autocomplete="postal-code" maxlength="20"> | |
| 181 | </div> | |
| 182 | <div class="co-field"> | |
| 183 | <label for="co-ship-country">Country</label> | |
| 184 | <input type="text" id="co-ship-country" name="ship_country" required autocomplete="country" maxlength="2" placeholder="US" style="text-transform:uppercase"> | |
| 185 | </div> | |
| 186 | </div> | |
| 187 | <div class="co-field"> | |
| 188 | <label for="co-ship-phone">Phone <span style="color:var(--col-text-3);font-weight:400">(optional, for delivery)</span></label> | |
| 189 | <input type="tel" id="co-ship-phone" name="ship_phone" autocomplete="tel" maxlength="40"> | |
| 190 | </div> | |
| 191 | ||
| 192 | <div style="font-size:13px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-2);margin:18px 0 10px">Shipping option</div> | |
| 193 | <div id="co-ship-zones" style="display:flex;flex-direction:column;gap:8px"> | |
| 194 | [loop:@ship_options] | |
| 195 | <label style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--col-border);border-radius:8px;cursor:pointer;background:var(--col-surface-2)"> | |
| 196 | <input type="radio" name="ship_zone" value="$loop1.slug" data-cents="$loop1.cents" $loop1.checked_attr> | |
| 197 | <span style="flex:1;font-weight:600">$loop1.label</span> | |
| 198 | <span class="font-mono" style="color:var(--col-accent-bright)">$loop1.cost_label</span> | |
| 199 | </label> | |
| 200 | [/loop] | |
| 201 | </div> | |
| 202 | </div> | |
| 203 | [/if] | |
| 204 | ||
| 205 | <div class="co-field" style="margin-top:22px"> | |
| 206 | <label>Card details</label> | |
| 207 | <div id="payment-element"> | |
| 208 | <div style="color:var(--col-text-3);font-size:13px;text-align:center;padding:8px">Loading secure payment form...</div> | |
| 209 | </div> | |
| 210 | </div> | |
| 211 | <div class="co-err" id="co-err"></div> | |
| 212 | <button type="submit" id="co-submit" class="co-btn">Pay $total_display</button> | |
| 213 | <div class="co-foot"> | |
| 214 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> | |
| 215 | Secured by Stripe. WebSTLs never sees your card. | |
| 216 | </div> | |
| 217 | </form> | |
| 218 | ||
| 219 | <script src="https://js.stripe.com/v3/"></script> | |
| 220 | <script> | |
| 221 | (function(){ | |
| 222 | var stripe = Stripe('$stripe_pk'); | |
| 223 | var elements = null; | |
| 224 | var clientSecret = null; | |
| 225 | var orderId = null; | |
| 226 | var form = document.getElementById('payment-form'); | |
| 227 | var submit = document.getElementById('co-submit'); | |
| 228 | var emailInput = document.getElementById('co-email'); | |
| 229 | var errBox = document.getElementById('co-err'); | |
| 230 | var countrySel = document.getElementById('co-bill-country'); | |
| 231 | var regionInput = document.getElementById('co-bill-region'); | |
| 232 | var regionWrap = document.getElementById('co-region-wrap'); | |
| 233 | var regionLabel = document.getElementById('co-region-label'); | |
| 234 | var taxRow = document.getElementById('co-tax-row'); | |
| 235 | var taxOut = document.getElementById('co-tax'); | |
| 236 | var taxLabel = document.getElementById('co-tax-label'); | |
| 237 | var totalOut = document.getElementById('co-total'); | |
| 238 | var submitBtn = document.getElementById('co-submit'); | |
| 239 | ||
| 240 | function fmtCents(c){ | |
| 241 | var v = (c || 0) / 100; | |
| 242 | return '$' + v.toFixed(2); | |
| 243 | } | |
| 244 | // Tax preview -- async refresh whenever country/region change. | |
| 245 | // Cart subtotal is server-rendered; we only update the tax + total | |
| 246 | // rows. PaymentIntent is still created on submit so a half-filled | |
| 247 | // form doesn't burn intents. | |
| 248 | var previewTimer = null; | |
| 249 | // Subtotal in cents (parsed once from the server-rendered | |
| 250 | // dollar string). Used to recompute the displayed total when | |
| 251 | // tax + shipping change. | |
| 252 | var subtotalCents = (function(){ | |
| 253 | var t = document.getElementById('co-subtotal').textContent || ''; | |
| 254 | var n = parseFloat(t.replace(/[^0-9.]/g, '')) || 0; | |
| 255 | return Math.round(n * 100); | |
| 256 | })(); | |
| 257 | function shipCents(){ | |
| 258 | var sel = document.querySelector('input[name="ship_zone"]:checked'); | |
| 259 | if (!sel) return 0; | |
| 260 | return parseInt(sel.getAttribute('data-cents') || '0', 10) || 0; | |
| 261 | } | |
| 262 | function updateTotal(taxCents){ | |
| 263 | var ship = shipCents(); | |
| 264 | var ship_el = document.getElementById('co-ship'); | |
| 265 | if (ship_el) ship_el.textContent = fmtCents(ship); | |
| 266 | var total = subtotalCents + ship + (taxCents || 0); | |
| 267 | totalOut.textContent = fmtCents(total); | |
| 268 | if (submitBtn) submitBtn.textContent = 'Pay ' + fmtCents(total); | |
| 269 | } | |
| 270 | document.querySelectorAll('input[name="ship_zone"]').forEach(function(r){ | |
| 271 | r.addEventListener('change', function(){ updateTotal(currentTaxCents); }); | |
| 272 | }); | |
| 273 | ||
| 274 | // "Same as billing" copies bill_* values into ship_* on toggle | |
| 275 | // and on every billing-field change. Unchecking exposes the | |
| 276 | // ship_* fields for independent entry. | |
| 277 | var sameToggle = document.getElementById('co-same-as-billing'); | |
| 278 | var shipFields = ['name','addr1','addr2','city','region','postal','country','phone']; | |
| 279 | var billPairs = { name:'name', addr1:'addr1', addr2:'addr2', city:'city', | |
| 280 | region:null, postal:'postal', country:null, phone:'phone' }; | |
| 281 | function copyBillToShip() { | |
| 282 | shipFields.forEach(function(suf){ | |
| 283 | var src = billPairs[suf]; | |
| 284 | if (!src) return; | |
| 285 | var b = document.getElementById('co-bill-' + src); | |
| 286 | var s = document.getElementById('co-ship-' + suf); | |
| 287 | if (b && s) s.value = b.value; | |
| 288 | }); | |
| 289 | // Pull ship region/country from the tax fields (which the | |
| 290 | // billing tax flow already collects). | |
| 291 | var sr = document.getElementById('co-ship-region'); | |
| 292 | if (sr && regionInput) sr.value = regionInput.value; | |
| 293 | var sc = document.getElementById('co-ship-country'); | |
| 294 | if (sc && countrySel) sc.value = countrySel.value; | |
| 295 | } | |
| 296 | function applySameToggle() { | |
| 297 | if (!sameToggle) return; | |
| 298 | var same = sameToggle.checked; | |
| 299 | shipFields.forEach(function(suf){ | |
| 300 | var el = document.getElementById('co-ship-' + suf); | |
| 301 | if (el) el.disabled = same; | |
| 302 | }); | |
| 303 | if (same) copyBillToShip(); | |
| 304 | } | |
| 305 | if (sameToggle) { | |
| 306 | sameToggle.addEventListener('change', applySameToggle); | |
| 307 | ['name','addr1','addr2','city','postal','phone'].forEach(function(suf){ | |
| 308 | var el = document.getElementById('co-bill-' + suf); | |
| 309 | if (el) el.addEventListener('input', function(){ if (sameToggle.checked) copyBillToShip(); }); | |
| 310 | }); | |
| 311 | if (regionInput) regionInput.addEventListener('input', function(){ if (sameToggle.checked) copyBillToShip(); }); | |
| 312 | if (countrySel) countrySel.addEventListener('change', function(){ if (sameToggle.checked) copyBillToShip(); }); | |
| 313 | applySameToggle(); | |
| 314 | } | |
| 315 | var currentTaxCents = 0; | |
| 316 | function refreshTax(){ | |
| 317 | var c = (countrySel.value || '').trim(); | |
| 318 | if (c === '' || c === 'OTHER') { | |
| 319 | taxRow.style.display = 'none'; | |
| 320 | currentTaxCents = 0; | |
| 321 | updateTotal(0); | |
| 322 | return; | |
| 323 | } | |
| 324 | var r = (regionInput.value || '').trim().toUpperCase(); | |
| 325 | var v = (vatInput.value || '').trim().toUpperCase(); | |
| 326 | var url = '/_tax_preview.cgi?storefront_id=$store_id&bill_country=' + encodeURIComponent(c) + '&bill_region=' + encodeURIComponent(r) + '&vat_id=' + encodeURIComponent(v); | |
| 327 | fetch(url, { credentials: 'same-origin' }) | |
| 328 | .then(function(rsp){ return rsp.json(); }) | |
| 329 | .then(function(j){ | |
| 330 | if (!j || !j.ok) return; | |
| 331 | if (j.tax_cents > 0) { | |
| 332 | var label = (j.lines && j.lines[0] && j.lines[0].label) ? j.lines[0].label : 'Tax'; | |
| 333 | taxLabel.textContent = label + ' (' + (j.lines[0].rate || '') + '%)'; | |
| 334 | taxOut.textContent = fmtCents(j.tax_cents); | |
| 335 | taxRow.style.display = ''; | |
| 336 | } else { | |
| 337 | taxRow.style.display = 'none'; | |
| 338 | } | |
| 339 | currentTaxCents = j.tax_cents || 0; | |
| 340 | updateTotal(currentTaxCents); | |
| 341 | }) | |
| 342 | .catch(function(){}); | |
| 343 | } | |
| 344 | function debouncedRefresh(){ | |
| 345 | if (previewTimer) clearTimeout(previewTimer); | |
| 346 | previewTimer = setTimeout(refreshTax, 250); | |
| 347 | } | |
| 348 | var vatWrap = document.getElementById('co-vat-wrap'); | |
| 349 | var vatInput = document.getElementById('co-vat-id'); | |
| 350 | var vatStatus = document.getElementById('co-vat-status'); | |
| 351 | // EU member states + Northern Ireland VAT use VIES. UK proper | |
| 352 | // (GBxxx) is also in VIES post-Brexit for NI numbers but most | |
| 353 | // GB checks now go through HMRC -- the VIES API still answers, | |
| 354 | // so we accept GB here too. | |
| 355 | var VIES_COUNTRIES = {AT:1,BE:1,BG:1,HR:1,CY:1,CZ:1,DK:1,EE:1,FI:1,FR:1,DE:1,EL:1,GR:1,HU:1,IE:1,IT:1,LV:1,LT:1,LU:1,MT:1,NL:1,PL:1,PT:1,RO:1,SK:1,SI:1,ES:1,SE:1,GB:1}; | |
| 356 | ||
| 357 | countrySel.addEventListener('change', function(){ | |
| 358 | var c = countrySel.value; | |
| 359 | if (c === 'US' || c === 'CA') { | |
| 360 | regionWrap.style.display = ''; | |
| 361 | regionLabel.textContent = (c === 'US') ? 'State (e.g. CA, TX)' : 'Province (e.g. ON, BC)'; | |
| 362 | } else { | |
| 363 | regionWrap.style.display = 'none'; | |
| 364 | regionInput.value = ''; | |
| 365 | } | |
| 366 | // VAT-ID field only useful for VIES countries. | |
| 367 | if (VIES_COUNTRIES[c]) { | |
| 368 | vatWrap.style.display = ''; | |
| 369 | } else { | |
| 370 | vatWrap.style.display = 'none'; | |
| 371 | vatInput.value = ''; | |
| 372 | vatStatus.textContent = ''; | |
| 373 | } | |
| 374 | refreshTax(); | |
| 375 | }); | |
| 376 | regionInput.addEventListener('input', debouncedRefresh); | |
| 377 | ||
| 378 | // VIES check fires on blur (avoid hammering the EU API on every keypress). | |
| 379 | vatInput.addEventListener('blur', function(){ | |
| 380 | var v = (vatInput.value || '').replace(/[^A-Za-z0-9]/g,'').toUpperCase(); | |
| 381 | vatInput.value = v; | |
| 382 | if (v.length < 4) { vatStatus.textContent = ''; return; } | |
| 383 | vatStatus.textContent = 'Validating via VIES...'; | |
| 384 | var fd = new FormData(); | |
| 385 | fd.append('vat_id', v); | |
| 386 | fd.append('email', (emailInput.value || '').trim()); | |
| 387 | fetch('/_vies_check.cgi', { method:'POST', body:fd, credentials:'same-origin' }) | |
| 388 | .then(function(r){ return r.json(); }) | |
| 389 | .then(function(j){ | |
| 390 | if (!j) { vatStatus.textContent = 'Check failed.'; return; } | |
| 391 | if (j.valid) { | |
| 392 | vatStatus.style.color = '#4ade80'; | |
| 393 | vatStatus.textContent = 'VAT valid' + (j.name && j.name !== '---' ? (' -- ' + j.name) : '') + '. Tax exempted under reverse-charge.'; | |
| 394 | refreshTax(); | |
| 395 | } else { | |
| 396 | vatStatus.style.color = '#fca5a5'; | |
| 397 | vatStatus.textContent = 'Not valid in VIES' + (j.note ? (' (' + j.note + ')') : '') + '. Tax will be charged.'; | |
| 398 | refreshTax(); | |
| 399 | } | |
| 400 | }) | |
| 401 | .catch(function(){ vatStatus.textContent = 'VIES check failed.'; }); | |
| 402 | }); | |
| 403 | ||
| 404 | function showErr(msg){ | |
| 405 | errBox.textContent = msg; | |
| 406 | errBox.classList.add('show'); | |
| 407 | } | |
| 408 | function hideErr(){ | |
| 409 | errBox.classList.remove('show'); | |
| 410 | } | |
| 411 | ||
| 412 | // Step 1: ask the server for a PaymentIntent client_secret. | |
| 413 | // We do this on first submit click rather than page load so | |
| 414 | // abandoned-checkout intents don't pile up in Stripe. | |
| 415 | function ensureIntent(email){ | |
| 416 | if (clientSecret) return Promise.resolve(); | |
| 417 | var fd = new FormData(); | |
| 418 | fd.append('storefront_id', '$store_id'); | |
| 419 | fd.append('email', email); | |
| 420 | fd.append('bill_country', (countrySel.value || '').trim()); | |
| 421 | fd.append('bill_region', (regionInput.value || '').trim().toUpperCase()); | |
| 422 | fd.append('vat_id', (vatInput.value || '').trim().toUpperCase()); | |
| 423 | // Billing address (always collected for the card on file). | |
| 424 | fd.append('bill_name', document.getElementById('co-bill-name').value || ''); | |
| 425 | fd.append('bill_addr1', document.getElementById('co-bill-addr1').value || ''); | |
| 426 | fd.append('bill_addr2', document.getElementById('co-bill-addr2').value || ''); | |
| 427 | fd.append('bill_city', document.getElementById('co-bill-city').value || ''); | |
| 428 | fd.append('bill_postal', document.getElementById('co-bill-postal').value || ''); | |
| 429 | fd.append('bill_phone', document.getElementById('co-bill-phone').value || ''); | |
| 430 | // Shipping fields: only sent when the address form is on the page. | |
| 431 | var shipForm = document.getElementById('co-ship-name'); | |
| 432 | if (shipForm) { | |
| 433 | var pick = document.querySelector('input[name="ship_zone"]:checked'); | |
| 434 | fd.append('ship_zone', pick ? pick.value : ''); | |
| 435 | fd.append('ship_cents', shipCents()); | |
| 436 | fd.append('ship_name', document.getElementById('co-ship-name').value || ''); | |
| 437 | fd.append('ship_addr1', document.getElementById('co-ship-addr1').value || ''); | |
| 438 | fd.append('ship_addr2', document.getElementById('co-ship-addr2').value || ''); | |
| 439 | fd.append('ship_city', document.getElementById('co-ship-city').value || ''); | |
| 440 | fd.append('ship_region', document.getElementById('co-ship-region').value || ''); | |
| 441 | fd.append('ship_postal', document.getElementById('co-ship-postal').value || ''); | |
| 442 | fd.append('ship_country', (document.getElementById('co-ship-country').value || '').toUpperCase()); | |
| 443 | fd.append('ship_phone', document.getElementById('co-ship-phone').value || ''); | |
| 444 | } | |
| 445 | return fetch('/checkout_intent.cgi', { method: 'POST', body: fd }) | |
| 446 | .then(function(r){ return r.json(); }) | |
| 447 | .then(function(j){ | |
| 448 | if (!j.ok) throw new Error(j.error || 'Could not start checkout.'); | |
| 449 | // 100%-off promos (or zero-priced carts) come back with | |
| 450 | // free=1 and a redirect URL. The order is already paid | |
| 451 | // on the server -- skip Stripe Elements entirely and | |
| 452 | // navigate straight to the completion page. | |
| 453 | if (j.free) { | |
| 454 | window.location = j.redirect || ('/checkout_complete.cgi?order=' + j.order_id); | |
| 455 | // Resolve to a never-resolving promise so the rest of | |
| 456 | // the submit pipeline (which calls stripe.confirmPayment) | |
| 457 | // never runs after the redirect kicks off. | |
| 458 | return new Promise(function(){}); | |
| 459 | } | |
| 460 | clientSecret = j.client_secret; | |
| 461 | orderId = j.order_id; | |
| 462 | elements = stripe.elements({ clientSecret: clientSecret, appearance: { theme: 'night' } }); | |
| 463 | var pe = elements.create('payment'); | |
| 464 | pe.mount('#payment-element'); | |
| 465 | return new Promise(function(res){ setTimeout(res, 300); }); | |
| 466 | }); | |
| 467 | } | |
| 468 | ||
| 469 | form.addEventListener('submit', function(e){ | |
| 470 | e.preventDefault(); | |
| 471 | hideErr(); | |
| 472 | var email = (emailInput.value || '').trim(); | |
| 473 | if (!email || email.indexOf('@') < 0) { showErr('Enter your email first.'); return; } | |
| 474 | submit.disabled = true; | |
| 475 | submit.textContent = 'Working...'; | |
| 476 | ||
| 477 | ensureIntent(email) | |
| 478 | .then(function(){ | |
| 479 | return stripe.confirmPayment({ | |
| 480 | elements: elements, | |
| 481 | confirmParams: { | |
| 482 | return_url: window.location.origin + '/checkout_complete.cgi?order=' + orderId, | |
| 483 | receipt_email: email | |
| 484 | } | |
| 485 | }); | |
| 486 | }) | |
| 487 | .then(function(result){ | |
| 488 | if (result && result.error) { | |
| 489 | showErr(result.error.message || 'Payment failed.'); | |
| 490 | submit.disabled = false; | |
| 491 | submit.textContent = 'Pay $total_display'; | |
| 492 | } | |
| 493 | }) | |
| 494 | .catch(function(err){ | |
| 495 | showErr(err.message || String(err)); | |
| 496 | submit.disabled = false; | |
| 497 | submit.textContent = 'Pay $total_display'; | |
| 498 | }); | |
| 499 | }); | |
| 500 | })(); | |
| 501 | </script> | |
| 502 | [/if] | |
| 503 | ||
| 504 | [if:$seller_not_ready] | |
| 505 | <div class="co-coming">Checkout · coming soon</div> | |
| 506 | <h1>Almost there.</h1> | |
| 507 | <p> | |
| 508 | $store_name hasn't finished connecting payments yet. Your cart is saved -- as soon as $store_name | |
| 509 | finishes onboarding with Stripe, you'll be able to complete this purchase right here. | |
| 510 | </p> | |
| 511 | <div style="margin-top:18px"> | |
| 512 | [loop:@items] | |
| 513 | <div class="co-line"><span>$loop1.title<span class="qty">x$loop1.qty</span></span><span class="font-mono">$loop1.line_total</span></div> | |
| 514 | [/loop] | |
| 515 | <div class="co-total-row"><span>Total</span><span class="font-mono">$total_display</span></div> | |
| 516 | </div> | |
| 517 | <div style="margin-top:18px;display:flex;gap:10px;flex-wrap:wrap"> | |
| 518 | <a class="co-secondary" href="$cart_href">Review cart</a> | |
| 519 | <a class="co-secondary" href="$back_href">Back to $store_name</a> | |
| 520 | </div> | |
| 521 | [/if] | |
| 522 | ||
| 523 | [if:$platform_not_ready] | |
| 524 | <div class="co-coming">Checkout · offline</div> | |
| 525 | <h1>Payments are temporarily unavailable.</h1> | |
| 526 | <p> | |
| 527 | WebSTLs is between deployments. Your cart is saved here -- come back in a few minutes and complete | |
| 528 | your purchase. Sorry for the detour. | |
| 529 | </p> | |
| 530 | <div style="margin-top:18px;display:flex;gap:10px;flex-wrap:wrap"> | |
| 531 | <a class="co-secondary" href="$back_href">Back to $store_name</a> | |
| 532 | </div> | |
| 533 | [/if] | |
| 534 | </div> | |
| 535 | [/if] | |
| 536 | ||
| 537 | [if:!$has_items] | |
| 538 | <div class="co-card" style="text-align:center"> | |
| 539 | <h1>Your cart is empty</h1> | |
| 540 | <p>Nothing to check out. Browse $store_name and add some designs first.</p> | |
| 541 | <div style="margin-top:18px"> | |
| 542 | <a class="co-secondary" href="$back_href">Back to $store_name</a> | |
| 543 | </div> | |
| 544 | </div> | |
| 545 | [/if] | |
| 546 | ||
| 547 | </main> | |
| 548 | </body> | |
| 549 | </html> |