1/** 2 * Spirit Store cart + checkout. Vanilla JS, no dependencies. 3 * 4 * The cart lives in localStorage (rt_cart_v2) and its stored prices are 5 * display-only: the checkout page re-prices everything through /store/quote 6 * and the server rebuilds the whole quote again inside /store/pay, so nothing 7 * a browser can edit changes what gets charged. 8 * 9 * A cart line is {product_id, variation_id, name, variation_name, price_cents, 10 * qty, modifiers:[{id, name, price_cents}]}. Lines merge only when both the 11 * variation and the full modifier set match. 12 * 13 * Config arrives via wp_localize_script as window.rtStoreCfg: 14 * application_id, location_id, env, rest_url, checkout_url, context. 15 */ 16(function () { 17 'use strict'; 18 19 var KEY = 'rt_cart_v2'; 20 // The payment idempotency key persists per attempt: a retry after a dropped 21 // connection reuses it so Square cannot charge twice. It resets whenever 22 // the cart changes or an attempt definitively resolves. 23 var PAY_KEY = 'rt_pay_key_v1'; 24 var cfg = window.rtStoreCfg || null; 25 26 /* ---- Cart storage ---- */ 27 28 function loadCart() { 29 try { 30 var items = JSON.parse(window.localStorage.getItem(KEY) || '[]'); 31 if (!Array.isArray(items)) { return []; } 32 return items.filter(function (it) { 33 return it && typeof it.variation_id === 'string' && it.variation_id && (it.qty | 0) > 0; 34 }).map(function (it) { 35 it.modifiers = Array.isArray(it.modifiers) ? it.modifiers.filter(function (m) { 36 return m && typeof m.id === 'string' && m.id; 37 }) : []; 38 return it; 39 }); 40 } catch (e) { return []; } 41 } 42 43 function saveCart(items) { 44 try { window.localStorage.setItem(KEY, JSON.stringify(items)); } catch (e) { /* private mode: cart just won't persist */ } 45 clearPayKey(); 46 } 47 48 function lineKey(variationId, modifiers) { 49 // Text personalization joins the key too: two jerseys with different 50 // names are two lines, never merged. 51 var ids = (modifiers || []).map(function (m) { 52 if (typeof m === 'string') { return m; } 53 return m.text !== undefined ? m.id + '=' + m.text : m.id; 54 }); 55 return variationId + '|' + ids.slice().sort().join(','); 56 } 57 58 function unitCents(it) { 59 return (it.price_cents || 0) + it.modifiers.reduce(function (sum, m) { return sum + (m.price_cents || 0); }, 0); 60 } 61 62 function cartCount(items) { 63 return items.reduce(function (n, it) { return n + it.qty; }, 0); 64 } 65 66 function cartTotal(items) { 67 return items.reduce(function (sum, it) { return sum + unitCents(it) * it.qty; }, 0); 68 } 69 70 function fmt(cents) { 71 var opts = (cents % 100 === 0) ? {} : { minimumFractionDigits: 2, maximumFractionDigits: 2 }; 72 return '$' + (cents / 100).toLocaleString('en-US', opts); 73 } 74 75 function payKey() { 76 var k = null; 77 try { k = window.sessionStorage.getItem(PAY_KEY); } catch (e) { } 78 if (!k) { 79 k = makeUuid(); 80 try { window.sessionStorage.setItem(PAY_KEY, k); } catch (e) { } 81 } 82 return k; 83 } 84 85 function clearPayKey() { 86 try { window.sessionStorage.removeItem(PAY_KEY); } catch (e) { } 87 } 88 89 function makeUuid() { 90 if (window.crypto && window.crypto.randomUUID) { return window.crypto.randomUUID(); } 91 var bytes = new Uint8Array(16); 92 window.crypto.getRandomValues(bytes); 93 bytes[6] = (bytes[6] & 0x0f) | 0x40; 94 bytes[8] = (bytes[8] & 0x3f) | 0x80; 95 var hex = Array.prototype.map.call(bytes, function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); 96 return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20); 97 } 98 99 function postJson(path, body) { 100 return window.fetch(cfg.rest_url + path, { 101 method: 'POST', 102 headers: { 'Content-Type': 'application/json' }, 103 body: JSON.stringify(body) 104 }).then(function (response) { 105 return response.json().then(function (json) { 106 return { ok: response.ok, body: json }; 107 }); 108 }); 109 } 110 111 function itemsPayload() { 112 return loadCart().map(function (it) { 113 return { 114 variation_id: it.variation_id, 115 qty: it.qty, 116 amount_cents: it.amount_cents || 0, 117 modifiers: it.modifiers.map(function (m) { 118 return m.text !== undefined ? { id: m.id, text: m.text } : m.id; 119 }) 120 }; 121 }); 122 } 123 124 /* ---- Store grid + detail: steppers, Add to Cart ---- */ 125
126 function initStore() { 127 // The typed-amount field shows only while the Any Amount option is chosen. 128 document.addEventListener('change', function (e) { 129 var sel = e.target && e.target.classList && e.target.classList.contains('rt-store-variations') ? e.target : null; 130 if (!sel) { return; } 131 var wrap = sel.closest('.rt-store-ctas'); 132 var amountWrap = wrap ? wrap.querySelector('.rt-store-amount-wrap') : null; 133 if (amountWrap) { amountWrap.hidden = sel.options[sel.selectedIndex].getAttribute('data-variable') !== '1'; } 134 }); 135 document.addEventListener('change', function (e) { 136 if (e.target && e.target.classList && e.target.classList.contains('rt-store-mod-input')) { 137 var ctas = e.target.closest('.rt-store-ctas'); 138 if (ctas) { 139 refreshTextRequirements(ctas); 140 refreshAddState(ctas); 141 } 142 } 143 }); 144 // Typing into a personalization field re-arms (or re-disarms) Add live. 145 document.addEventListener('input', function (e) { 146 if (e.target && e.target.classList && e.target.classList.contains('rt-store-mod-text')) { 147 var ctas = e.target.closest('.rt-store-ctas'); 148 if (ctas) { refreshAddState(ctas); } 149 } 150 }); 151 // First paint: sync requirement marks and Add buttons on every product. 152 Array.prototype.forEach.call(document.querySelectorAll('.rt-store-ctas'), function (ctas) { 153 refreshTextRequirements(ctas); 154 refreshAddState(ctas); 155 }); 156 document.addEventListener('click', function (e) { 157 var step = e.target && e.target.closest ? e.target.closest('.rt-store-stepbtn') : null; 158 if (step) { 159 var stepper = step.closest('.rt-store-stepper'); 160 var input = stepper ? stepper.querySelector('.rt-store-qty') : null; 161 if (input) { 162 var value = (parseInt(input.value, 10) || 1) + (step.getAttribute('data-step') === '-1' ? -1 : 1); 163 input.value = Math.max(1, Math.min(20, value)); 164 } 165 return; 166 } 167 168 var btn = e.target && e.target.closest ? e.target.closest('.rt-store-add') : null; 169 if (!btn) { return; } 170 171 var item = { 172 product_id: btn.getAttribute('data-product-id') || '', 173 variation_id: btn.getAttribute('data-variation-id') || '', 174 name: btn.getAttribute('data-name') || '', 175 variation_name: '', 176 price_cents: parseInt(btn.getAttribute('data-price-cents'), 10) || 0, 177 qty: 1, 178 modifiers: [], 179 // A themed item (Pink Out) celebrates in its own colors when it 180 // lands in the cart and again on the thank-you. 181 theme: btn.getAttribute('data-theme') || '' 182 }; 183 184 var wrap = btn.closest('.rt-store-ctas'); 185 var select = wrap ? wrap.querySelector('.rt-store-variations') : null; 186 var variable = false; 187 if (select && select.value) { 188 var opt = select.options[select.selectedIndex]; 189 item.variation_id = select.value; 190 item.price_cents = parseInt(opt.getAttribute('data-price-cents'), 10) || item.price_cents; 191 variable = opt.getAttribute('data-variable') === '1'; 192 // Option labels may carry a "(price)" suffix; the cart shows just the name. 193 item.variation_name = (opt.textContent || '').replace(/\s*\([^)]*\)\s*$/, '').trim(); 194 } 195 if (!item.variation_id) { return; } 196 // A variable-priced variation (a donation's Any Amount) sells at the typed amount. 197 var amountInput = wrap ? wrap.querySelector('.rt-store-amount') : null; 198 if (variable || (amountInput && !select)) { 199 var dollars = amountInput ? parseFloat(amountInput.value) : 0; 200 if (!(dollars >= 5)) { flash(btn, 'Type an amount of $5 or more'); if (amountInput) { amountInput.focus(); } return; } 201 item.amount_cents = Math.round(dollars * 100); 202 item.price_cents = item.amount_cents; 203 item.variation_name = fmt(item.amount_cents) + ' gift'; 204 } 205 206 // Modifier groups (detail page): collect the checked choices, and 207 // hold the Add until a MULTIPLE group's min/max is satisfied. 208 if (wrap && !collectModifiers(wrap, item)) { 209 flash(btn, 'Check your options first'); 210 return; 211 } 212 213 var qtyInput = wrap ? wrap.querySelector('.rt-store-qty') : null; 214 if (qtyInput) { 215 var qty = parseInt(qtyInput.value, 10); 216 item.qty = (qty > 0) ? Math.min(20, qty) : 1; 217 } 218 219 var items = loadCart(); 220 var key = lineKey(item.variation_id, item.modifiers) + '|' + (item.amount_cents || 0); 221 var merged = false;
222 for (var i = 0; i < items.length; i++) { 223 if (lineKey(items[i].variation_id, items[i].modifiers) + '|' + (items[i].amount_cents || 0) === key) { 224 items[i].qty = Math.min(20, items[i].qty + item.qty); 225 merged = true; 226 break; 227 } 228 } 229 if (!merged) { items.push(item); } 230 saveCart(items); 231 renderBars(); 232 flash(btn, 'Added!'); 233 celebrate(item.theme, btn); 234 // A one-step buy (a sponsorship level) heads straight to checkout. 235 if (btn.getAttribute('data-rt-go') === 'checkout' && cfg && cfg.checkout_url) { 236 window.location.href = cfg.checkout_url; 237 } 238 }); 239 } 240 241 /* A text field flagged numeric (a phone number) only ever holds digits: 242 stripped as you type, and again when the cart collects it. */ 243 document.addEventListener('input', function (e) { 244 var t = e.target; 245 if (t && t.classList && t.classList.contains('rt-store-mod-text') && t.hasAttribute('data-numeric')) { 246 var digits = t.value.replace(/\D+/g, ''); 247 if (digits !== t.value) { t.value = digits; } 248 } 249 }); 250 251 /** 252 * What a choice control has chosen, as a flat list, so the rest of the 253 * cart does not care whether the shopper used a dropdown (pick one) or a 254 * checkbox (pick several). Price and name ride on the option for a 255 * select and on the input for a checkbox. 256 */ 257 function chosenFrom(el) { 258 if ('SELECT' === el.tagName) { 259 var opt = el.options[el.selectedIndex]; 260 if (!opt || !opt.value) { return []; } 261 return [{ 262 value: opt.value, 263 name: opt.getAttribute('data-mod-name') || '', 264 cents: parseInt(opt.getAttribute('data-price-cents'), 10) || 0 265 }]; 266 } 267 if (!el.checked || !el.value) { return []; } 268 return [{ 269 value: el.value, 270 name: el.getAttribute('data-mod-name') || '', 271 cents: parseInt(el.getAttribute('data-price-cents'), 10) || 0 272 }]; 273 } 274 275 /** 276 * The teacher dropdown knows the school's roster, grouped by grade. 277 * Picking a grade in the grade dropdown floats that grade's group to the 278 * top of the list rather than hiding the rest, so a specialist or a 279 * teacher in another grade is always still reachable. 280 */ 281 function wireTeacherPickers(scope) { 282 (scope || document).querySelectorAll('select[data-rt-teacher]:not([data-rt-teacher-wired])').forEach(function (sel) { 283 sel.setAttribute('data-rt-teacher-wired', '1'); 284 var wrap = sel.closest('.rt-store-detail-ctas') || document; 285 var groups = Array.prototype.slice.call(sel.querySelectorAll('optgroup')); 286 if (!groups.length) { return; } 287 288 function gradeChosen() { 289 var pick = Array.prototype.filter.call(wrap.querySelectorAll('.rt-store-mod-select'), function (s) { 290 if (s === sel) { return false; } 291 var leg = s.closest('.rt-store-mod-group'); 292 leg = leg && leg.querySelector('legend'); 293 return leg && /grade/i.test(leg.textContent || ''); 294 })[0]; 295 if (!pick) { return ''; } 296 var opt = pick.options[pick.selectedIndex]; 297 return opt ? (opt.getAttribute('data-mod-name') || '').trim() : ''; 298 } 299 300 function reorder() { 301 var g = gradeChosen().toLowerCase(); 302 var had = sel.value; 303 var top = groups.filter(function (og) { return g && (og.getAttribute('data-grade') || '').toLowerCase() === g; }); 304 var rest = groups.filter(function (og) { return top.indexOf(og) === -1; }); 305 groups.forEach(function (og) { sel.removeChild(og); }); 306 top.concat(rest).forEach(function (og) { sel.appendChild(og); }); 307 sel.value = had; 308 } 309 310 wrap.addEventListener('change', function (e) { 311 if (e.target && e.target !== sel && e.target.classList && e.target.classList.contains('rt-store-mod-select')) { reorder(); } 312 }); 313 reorder(); 314 }); 315 } 316 317 function wireSearchableSelects(scope) { 318 (scope || document).querySelectorAll('select[data-searchable]:not([data-searched])').forEach(function (sel) { 319 sel.setAttribute('data-searched', '1'); 320 var all = Array.prototype.slice.call(sel.options); 321 var box = document.createElement('input'); 322 box.type = 'search'; 323 box.className = 'rt-store-mod-search'; 324 box.placeholder = 'Type to filter...'; 325 box.setAttribute('aria-label', 'Filter the choices below'); 326 sel.parentNode.insertBefore(box, sel); 327 box.addEventListener('input', function () { 328 var q = box.value.trim().toLowerCase(); 329 var keep = all.filter(function (o) { 330 return !q || o.textContent.toLowerCase().indexOf(q) !== -1 || !o.value; 331 }); 332 var had = sel.value; 333 while (sel.firstChild) { sel.removeChild(sel.firstChild); } 334 keep.forEach(function (o) { sel.appendChild(o); }); 335 if (keep.some(function (o) { return o.value === had; })) { 336 sel.value = had; 337 } 338 sel.dispatchEvent(new Event('change', { bubbles: true })); 339 }); 340 }); 341 } 342 343 /** Every choice made inside a group, dropdowns and checkboxes alike. */ 344 function chosenIn(group) { 345 var out = []; 346 Array.prototype.forEach.call(group.querySelectorAll('.rt-store-mod-input'), function (el) { 347 out = out.concat(chosenFrom(el)); 348 }); 349 return out; 350 } 351 352 /** True when any chosen option in the wrap costs money: paid personalization means its text fields are needed. */ 353 function paidChoiceChosen(wrap) { 354 return Array.prototype.some.call(wrap.querySelectorAll('.rt-store-mod-input'), function (el) { 355 if (el.closest('[data-donation]')) { return false; } 356 return chosenFrom(el).some(function (c) { return c.cents > 0; }); 357 }); 358 } 359 360 /** 361 * Fills item.modifiers from the checked inputs; false when a group's 362 * bounds are broken or a required text field is empty. Choice groups go 363 * first so text fields know whether a PAID option was picked: paid 364 * personalization makes every text field on the product required, Square's 365 * own required flag or not. 366 */ 367 function collectModifiers(wrap, item) {
368 var valid = true; 369 var paid = false; 370 Array.prototype.forEach.call(wrap.querySelectorAll('.rt-store-mod-group'), function (group) { 371 if (group.querySelector('.rt-store-mod-text')) { return; } 372 var checked = chosenIn(group); 373 var min = parseInt(group.getAttribute('data-min'), 10); 374 var max = parseInt(group.getAttribute('data-max'), 10); 375 var broken = (!isNaN(min) && checked.length < min) || (!isNaN(max) && checked.length > max); 376 group.classList.toggle('is-short', broken); 377 if (broken) { valid = false; return; } 378 var donation = group.hasAttribute('data-donation'); 379 checked.forEach(function (choice) { 380 // A donation add-on costs money but is not personalization; 381 // it must not make the optional text fields required. 382 if (choice.cents > 0 && !donation) { paid = true; } 383 item.modifiers.push({ 384 id: choice.value, 385 name: choice.name, 386 price_cents: choice.cents 387 }); 388 }); 389 }); 390 Array.prototype.forEach.call(wrap.querySelectorAll('.rt-store-mod-group'), function (group) { 391 var text = group.querySelector('.rt-store-mod-text'); 392 if (!text) { return; } 393 var value = text.value.trim(); 394 if (text.hasAttribute('data-numeric')) { value = value.replace(/\D+/g, ''); text.value = value; } 395 var needed = text.getAttribute('data-required') === '1' || paid; 396 var missing = needed && !value; 397 group.classList.toggle('is-short', missing); 398 if (missing) { valid = false; return; } 399 if (value) { 400 item.modifiers.push({ 401 id: text.getAttribute('data-list-id') || '', 402 name: text.getAttribute('data-list-name') || '', 403 text: value, 404 price_cents: 0 405 }); 406 } 407 }); 408 return valid; 409 } 410 411 /** 412 * Live required-ness: the moment a paid choice is picked, the product's 413 * optional text fields turn required (the "(optional)" tag hides and the 414 * input gains required), and back again when it is unpicked. 415 */ 416 function refreshTextRequirements(wrap) { 417 var paid = paidChoiceChosen(wrap); 418 Array.prototype.forEach.call(wrap.querySelectorAll('.rt-store-mod-group'), function (group) { 419 var text = group.querySelector('.rt-store-mod-text'); 420 if (!text) { return; } 421 var always = text.getAttribute('data-required') === '1'; 422 text.required = always || paid; 423 text.setAttribute('aria-required', (always || paid) ? 'true' : 'false'); 424 // The is-required class draws the asterisk, so a paid choice 425 // flipping an optional field to required marks it on the spot. 426 group.classList.toggle('is-required', text.required); 427 var tag = group.querySelector('.rt-store-mod-optional'); 428 if (tag) { tag.hidden = paid; } 429 if (!text.required) { group.classList.remove('is-short'); } 430 }); 431 } 432 433 /** 434 * True when every requirement on a product's options is met: MULTIPLE 435 * choice groups inside their min/max, required (or paid-required) text 436 * fields filled. Mirrors collectModifiers without touching any classes, 437 * so Add to Cart can enable and disable live as the shopper types. 438 */ 439 function ctasValid(wrap) { 440 var paid = paidChoiceChosen(wrap); 441 var valid = true; 442 Array.prototype.forEach.call(wrap.querySelectorAll('.rt-store-mod-group'), function (group) { 443 var text = group.querySelector('.rt-store-mod-text'); 444 if (text) { 445 var needed = text.getAttribute('data-required') === '1' || paid; 446 if (needed && !text.value.trim()) { valid = false; } 447 return; 448 } 449 var checked = chosenIn(group); 450 var min = parseInt(group.getAttribute('data-min'), 10); 451 var max = parseInt(group.getAttribute('data-max'), 10); 452 if ((!isNaN(min) && checked.length < min) || (!isNaN(max) && checked.length > max)) { valid = false; } 453 }); 454 return valid; 455 } 456 457 /** Add to Cart waits, unclickable, until the options above it are whole. 458 * A tooltip on the disabled button tells the shopper why it is waiting. */ 459 function refreshAddState(wrap) { 460 var btn = wrap.querySelector('.rt-store-add'); 461 if (!btn || btn.hasAttribute('data-flashing')) { return; } 462 btn.disabled = !ctasValid(wrap); 463 if (btn.disabled) { 464 btn.setAttribute('data-tip', 'Fill in the required options above first.'); 465 } else { 466 btn.removeAttribute('data-tip'); 467 } 468 } 469 470 /* 471 * Confetti. The thank-you throws the site's colors; a themed item (Pink 472 * Out) throws its own from the button it was added with. Self-contained 473 * so the store never leans on another module's script; skipped for 474 * people who asked for reduced motion. 475 */ 476 var THEME_COLORS = { 477 pink: ['#ff69b4', '#ff1493', '#ffc0cb', '#e6007e', '#ffffff', '#f8bbd0'], 478 boo: ['#f26b1d', '#5b2a86', '#ffffff', '#ffb066', '#1b1430', '#fff6e9'] 479 }; 480 481 function cartTheme(items) { 482 for (var i = 0; i < items.length; i++) { 483 if (items[i].theme && THEME_COLORS[items[i].theme]) { return items[i].theme; } 484 } 485 return ''; 486 } 487 488 function celebrate(theme, from) { 489 var colors = THEME_COLORS[theme]; 490 if (!colors) { return; } 491 var origin = null; 492 if (from && from.getBoundingClientRect) { 493 var r = from.getBoundingClientRect(); 494 origin = { x: r.left + r.width / 2, y: r.top + r.height / 2 }; 495 } 496 confettiBurst(colors, origin, window.innerWidth > 720 ? 150 : 90, THEME_SHAPES[theme]); 497 } 498 499 /* A theme that celebrates with something other than confetti (boo = ghosts). */ 500 var THEME_SHAPES = { boo: 'ghost' }; 501 502 /* A little cartoon ghost: round head, wavy hem, two eyes. Drawn around (0,0), s tall. */ 503 function drawGhost(ctx, s, col) { 504 var w = s * 0.72, r = w / 2, top = -s / 2, hem = s / 2; 505 ctx.beginPath(); 506 ctx.moveTo(-r, top + r);
507 ctx.arc(0, top + r, r, Math.PI, 0); 508 ctx.lineTo(r, hem - s * 0.12); 509 var lobes = 3, lw = w / lobes; 510 for (var i = 0; i < lobes; i++) { 511 var x1 = r - lw * i, x2 = r - lw * (i + 1); 512 ctx.quadraticCurveTo((x1 + x2) / 2, hem + s * 0.14, x2, hem - s * 0.12); 513 } 514 ctx.closePath(); 515 ctx.fillStyle = col; ctx.fill(); 516 ctx.fillStyle = 'rgba(27,20,48,0.85)'; 517 ctx.beginPath(); ctx.arc(-r * 0.36, top + r * 0.95, s * 0.06, 0, Math.PI * 2); ctx.fill(); 518 ctx.beginPath(); ctx.arc(r * 0.36, top + r * 0.95, s * 0.06, 0, Math.PI * 2); ctx.fill(); 519 } 520 521 /* Ghosts rise and wobble instead of raining down. */ 522 function ghostRise(colors, origin, n) { 523 var c = document.createElement('canvas'); 524 c.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9600'; 525 document.body.appendChild(c); 526 var ctx = c.getContext('2d'); 527 var W = c.width = window.innerWidth, H = c.height = window.innerHeight; 528 var ox = origin ? origin.x : W / 2, oy = origin ? origin.y : H * 0.75, spread = origin ? 0.1 : 0.8; 529 var pale = ['#ffffff', '#fff6e9', '#ffffff', '#fff6e9', '#ffd6b8', '#ffffff']; 530 var ghosts = []; 531 for (var i = 0; i < n; i++) { 532 ghosts.push({ 533 x: ox + (Math.random() - 0.5) * W * spread, y: oy + (origin ? 0 : Math.random() * H * 0.2), 534 vy: -(1.6 + Math.random() * 2.6), s: 18 + Math.random() * 20, 535 ph: Math.random() * Math.PI * 2, amp: 10 + Math.random() * 18, 536 delay: Math.random() * 0.8, 537 col: (i % 5 === 4) ? colors[i % colors.length] : pale[i % pale.length] 538 }); 539 } 540 var start = Date.now(); 541 function frame() { 542 var t = (Date.now() - start) / 1000; 543 ctx.clearRect(0, 0, W, H); 544 ghosts.forEach(function (g) { 545 var a = t - g.delay; 546 if (a < 0) { return; } 547 g.y += g.vy; 548 var x = g.x + Math.sin(a * 3 + g.ph) * g.amp; 549 var tilt = Math.cos(a * 3 + g.ph) * 0.18; 550 ctx.save(); ctx.translate(x, g.y); ctx.rotate(tilt); 551 ctx.globalAlpha = Math.min(1, a * 3) * Math.max(0, 1 - Math.max(0, a - 2.2) / 0.8); 552 drawGhost(ctx, g.s, g.col); ctx.restore(); 553 }); 554 if (t < 3.8) { window.requestAnimationFrame(frame); } else { c.remove(); } 555 } 556 window.requestAnimationFrame(frame); 557 } 558 559 function confettiBurst(colors, origin, n, shape) { 560 if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; } 561 if (shape === 'ghost') { ghostRise(colors, origin, Math.round(n / 5)); return; } 562 var c = document.createElement('canvas'); 563 c.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9600'; 564 document.body.appendChild(c); 565 var ctx = c.getContext('2d'); 566 var W = c.width = window.innerWidth, H = c.height = window.innerHeight; 567 var ox = origin ? origin.x : W / 2, oy = origin ? origin.y : H * 0.35, spread = origin ? 0.06 : 0.6; 568 var bits = []; 569 for (var i = 0; i < n; i++) { 570 bits.push({ 571 x: ox + (Math.random() - 0.5) * W * spread, y: oy, 572 vx: (Math.random() - 0.5) * 14, vy: -Math.random() * 16 - 4, 573 w: 6 + Math.random() * 6, h: 8 + Math.random() * 8, 574 r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, 575 col: colors[i % colors.length] 576 }); 577 } 578 var start = Date.now(); 579 function frame() { 580 var t = (Date.now() - start) / 1000; 581 ctx.clearRect(0, 0, W, H); 582 bits.forEach(function (b) { 583 b.vy += 0.45; b.x += b.vx; b.y += b.vy; b.vx *= 0.99; b.r += b.vr; 584 ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.r); 585 ctx.globalAlpha = Math.max(0, 1 - Math.max(0, t - 1.6)); 586 ctx.fillStyle = b.col; ctx.fillRect(-b.w / 2, -b.h / 2, b.w, b.h); ctx.restore(); 587 }); 588 if (t < 2.6) { window.requestAnimationFrame(frame); } else { c.remove(); } 589 } 590 window.requestAnimationFrame(frame); 591 } 592 593 function flash(btn, text) { 594 var original = btn.textContent; 595 btn.disabled = true; 596 btn.setAttribute('data-flashing', '1'); 597 btn.textContent = text; 598 window.setTimeout(function () { 599 btn.textContent = original; 600 btn.removeAttribute('data-flashing'); 601 btn.disabled = false; 602 // An Add button only comes back if its options are still whole. 603 var ctas = btn.classList.contains('rt-store-add') ? btn.closest('.rt-store-ctas') : null; 604 if (ctas) { refreshAddState(ctas); } 605 }, 900); 606 } 607 608 /** 609 * The cart bar rides on every store surface (grid, product pages, the 610 * checkout) whenever the script ships at all, empty cart included: an 611 * always-there bar is the store's one steady landmark. It leads the store, 612 * sticky just below the site header, and its cart icon opens a dropdown 613 * summary so a shopper can peek without leaving the grid. The checkout
614 * page keeps a static, dropdown-less copy: its lines are already on the 615 * page. 616 */ 617 var barSeq = 0; 618 619 function renderBars() { 620 if (!cfg) { return; } 621 var items = loadCart(); 622 // The checkout page gets no bar at all: its order list, total, and 623 // form ARE the cart, so a summary bar above them is just noise. 624 var hosts = document.querySelectorAll('.rt-store'); 625 Array.prototype.forEach.call(hosts, function (host) { 626 var bar = host.querySelector('.rt-store-cartbar'); 627 if (!bar) { 628 bar = document.createElement('div'); 629 bar.className = 'rt-store-cartbar'; 630 var toggle = document.createElement('button'); 631 toggle.type = 'button'; 632 toggle.className = 'rt-store-cartbar-toggle'; 633 toggle.setAttribute('aria-expanded', 'false'); 634 toggle.setAttribute('aria-haspopup', 'true'); 635 var icon = document.createElement('i'); 636 icon.className = 'fa-solid fa-cart-shopping rt-store-cartbar-icon'; 637 icon.setAttribute('aria-hidden', 'true'); 638 var label = document.createElement('span'); 639 label.className = 'rt-store-cartbar-label'; 640 var caret = document.createElement('i'); 641 caret.className = 'fa-solid fa-chevron-down rt-store-cartbar-caret'; 642 caret.setAttribute('aria-hidden', 'true'); 643 toggle.appendChild(icon); 644 toggle.appendChild(label); 645 toggle.appendChild(caret); 646 var link = document.createElement('a'); 647 link.className = 'rt-store-btn'; 648 link.textContent = 'Checkout'; 649 var menu = document.createElement('div'); 650 menu.className = 'rt-store-cartbar-menu'; 651 menu.id = 'rt-cartbar-menu-' + (++barSeq); 652 menu.hidden = true; 653 menu.setAttribute('aria-label', 'Cart summary'); 654 toggle.setAttribute('aria-controls', menu.id); 655 bar.appendChild(toggle); 656 bar.appendChild(link); 657 bar.appendChild(menu); 658 host.insertBefore(bar, host.firstChild); 659 } 660 bar.hidden = false; 661 var labelEl = bar.querySelector('.rt-store-cartbar-label'); 662 var linkEl = bar.querySelector('a'); 663 var toggleEl = bar.querySelector('.rt-store-cartbar-toggle'); 664 if (!items.length) { 665 bar.classList.add('is-empty'); 666 labelEl.textContent = 'Your cart is empty'; 667 linkEl.classList.add('is-disabled'); 668 linkEl.setAttribute('aria-disabled', 'true'); 669 linkEl.removeAttribute('href'); 670 toggleEl.disabled = true; 671 closeBarMenu(bar); 672 } else { 673 bar.classList.remove('is-empty'); 674 var count = cartCount(items); 675 labelEl.textContent = count + (count === 1 ? ' item, ' : ' items, ') + fmt(cartTotal(items)); 676 linkEl.classList.remove('is-disabled'); 677 linkEl.removeAttribute('aria-disabled'); 678 linkEl.href = cfg.checkout_url || '/store/checkout/'; 679 toggleEl.disabled = false; 680 } 681 if (bar.classList.contains('is-open')) { 682 renderBarMenu(bar, items); 683 } 684 }); 685 } 686 687 /** 688 * The dropdown body: one row per cart line with its own qty stepper and 689 * remove button, then the item subtotal. Edits write straight back to the 690 * localStorage cart; the prices are the same display-only numbers the 691 * label uses, and the checkout still re-prices everything server-side. 692 */ 693 function renderBarMenu(bar, items) { 694 var menu = bar.querySelector('.rt-store-cartbar-menu'); 695 if (!menu) { return; } 696 menu.textContent = ''; 697 if (!items.length) { return; } 698 items.forEach(function (it) { 699 var line = document.createElement('div'); 700 line.className = 'rt-store-cbm-line'; 701 line.setAttribute('data-key', lineKey(it.variation_id, it.modifiers)); 702 var qtyCtl = document.createElement('span'); 703 qtyCtl.className = 'rt-store-cbm-qtyctl'; 704 var minus = document.createElement('button'); 705 minus.type = 'button'; 706 minus.className = 'rt-store-cbm-step'; 707 minus.setAttribute('data-step', '-1'); 708 minus.setAttribute('aria-label', 'One less ' + (it.name || 'item')); 709 minus.textContent = '−'; 710 minus.disabled = it.qty <= 1; 711 var qty = document.createElement('span'); 712 qty.className = 'rt-store-cbm-qty'; 713 qty.textContent = it.qty; 714 var plus = document.createElement('button'); 715 plus.type = 'button'; 716 plus.className = 'rt-store-cbm-step'; 717 plus.setAttribute('data-step', '1'); 718 plus.setAttribute('aria-label', 'One more ' + (it.name || 'item')); 719 plus.textContent = '+'; 720 plus.disabled = it.qty >= 20; 721 qtyCtl.appendChild(minus); 722 qtyCtl.appendChild(qty); 723 qtyCtl.appendChild(plus); 724 var body = document.createElement('span'); 725 body.className = 'rt-store-cbm-body'; 726 var name = document.createElement('span'); 727 name.className = 'rt-store-cbm-name'; 728 name.textContent = it.name || 'Item'; 729 body.appendChild(name); 730 if (it.variation_name) { 731 var variation = document.createElement('span'); 732 variation.className = 'rt-store-cbm-detail'; 733 variation.textContent = it.variation_name; 734 body.appendChild(variation); 735 }
736 (it.modifiers || []).forEach(function (m) { 737 if (!m.name && m.text === undefined) { return; } 738 var mod = document.createElement('span'); 739 mod.className = 'rt-store-cbm-detail'; 740 mod.textContent = m.text !== undefined 741 ? (m.name ? m.name + ': ' + m.text : m.text) 742 : m.name + ((m.price_cents || 0) > 0 ? ' (+' + fmt(m.price_cents) + ')' : ''); 743 body.appendChild(mod); 744 }); 745 var price = document.createElement('span'); 746 price.className = 'rt-store-cbm-price'; 747 price.textContent = fmt(unitCents(it) * it.qty); 748 var remove = document.createElement('button'); 749 remove.type = 'button'; 750 remove.className = 'rt-store-cbm-remove'; 751 remove.setAttribute('aria-label', 'Remove ' + (it.name || 'item') + ' from cart'); 752 var removeIcon = document.createElement('i'); 753 removeIcon.className = 'fa-solid fa-trash-can'; 754 removeIcon.setAttribute('aria-hidden', 'true'); 755 remove.appendChild(removeIcon); 756 line.appendChild(qtyCtl); 757 line.appendChild(body); 758 line.appendChild(price); 759 line.appendChild(remove); 760 menu.appendChild(line); 761 }); 762 var foot = document.createElement('div'); 763 foot.className = 'rt-store-cbm-line rt-store-cbm-subtotal'; 764 var footLabel = document.createElement('span'); 765 footLabel.className = 'rt-store-cbm-body'; 766 footLabel.textContent = 'Subtotal'; 767 var footPrice = document.createElement('span'); 768 footPrice.className = 'rt-store-cbm-price'; 769 footPrice.textContent = fmt(cartTotal(items)); 770 foot.appendChild(footLabel); 771 foot.appendChild(footPrice); 772 menu.appendChild(foot); 773 } 774 775 function openBarMenu(bar) { 776 renderBarMenu(bar, loadCart()); 777 bar.classList.add('is-open'); 778 bar.querySelector('.rt-store-cartbar-menu').hidden = false; 779 bar.querySelector('.rt-store-cartbar-toggle').setAttribute('aria-expanded', 'true'); 780 } 781 782 function closeBarMenu(bar) { 783 if (!bar.classList.contains('is-open')) { return; } 784 bar.classList.remove('is-open'); 785 var menu = bar.querySelector('.rt-store-cartbar-menu'); 786 if (menu) { menu.hidden = true; } 787 var toggle = bar.querySelector('.rt-store-cartbar-toggle'); 788 if (toggle) { toggle.setAttribute('aria-expanded', 'false'); } 789 } 790 791 function closeAllBarMenus() { 792 Array.prototype.forEach.call(document.querySelectorAll('.rt-store-cartbar.is-open'), closeBarMenu); 793 } 794 795 /** Rewrites one cart line's qty from its dropdown stepper (clamped 1..20). */ 796 function adjustBarLine(lineEl, delta) { 797 if (!lineEl || !delta) { return; } 798 var key = lineEl.getAttribute('data-key'); 799 var items = loadCart(); 800 for (var i = 0; i < items.length; i++) { 801 if (lineKey(items[i].variation_id, items[i].modifiers) === key) { 802 items[i].qty = Math.max(1, Math.min(20, items[i].qty + delta)); 803 break; 804 } 805 } 806 saveCart(items); 807 renderBars(); 808 } 809 810 /** Drops one line from the cart via its dropdown remove button. */ 811 function removeBarLine(lineEl) { 812 if (!lineEl) { return; } 813 var key = lineEl.getAttribute('data-key'); 814 var items = loadCart().filter(function (it) { 815 return lineKey(it.variation_id, it.modifiers) !== key; 816 }); 817 saveCart(items); 818 renderBars(); 819 } 820 821 function initBarMenus() { 822 document.addEventListener('click', function (e) { 823 if (e.target && e.target.closest) { 824 var step = e.target.closest('.rt-store-cbm-step'); 825 if (step) { 826 adjustBarLine(step.closest('.rt-store-cbm-line'), parseInt(step.getAttribute('data-step'), 10) || 0); 827 return; 828 } 829 var remove = e.target.closest('.rt-store-cbm-remove'); 830 if (remove) { 831 removeBarLine(remove.closest('.rt-store-cbm-line')); 832 return; 833 } 834 } 835 var toggle = e.target && e.target.closest ? e.target.closest('.rt-store-cartbar-toggle') : null; 836 if (toggle && !toggle.disabled) { 837 var bar = toggle.closest('.rt-store-cartbar'); 838 var wasOpen = bar.classList.contains('is-open'); 839 closeAllBarMenus(); 840 if (!wasOpen) { openBarMenu(bar); } 841 return; 842 } 843 if (!(e.target && e.target.closest && e.target.closest('.rt-store-cartbar'))) { 844 closeAllBarMenus(); 845 } 846 }); 847 document.addEventListener('keydown', function (e) { 848 if (e.key === 'Escape') { closeAllBarMenus(); } 849 }); 850 } 851 852 /** 853 * The bar sits sticky at the top of the store, and the theme header is 854 * sticky too: measure the header so the bar tucks in just below it instead 855 * of sliding underneath. Falls back cleanly when a site runs a theme 856 * without a sticky header. 857 */ 858 function syncBarOffset() { 859 var header = document.querySelector('.rt-site-header'); 860 var offset = 10; 861 if (header) { 862 var cs = window.getComputedStyle(header); 863 if (cs.position === 'sticky' || cs.position === 'fixed') { 864 offset = header.offsetHeight + 10; 865 } 866 } 867 document.documentElement.style.setProperty('--rt-cartbar-top', offset + 'px'); 868 } 869 870 /* ---- Checkout page ---- */ 871 872 function initCheckout() { 873 var root = document.querySelector('.rt-store-checkout'); 874 if (!root || !cfg || !cfg.application_id) { return; } 875 876 var el = { 877 empty: root.querySelector('[data-rt-co-empty]'), 878 main: root.querySelector('[data-rt-co-main]'), 879 lines: root.querySelector('[data-rt-co-lines]'), 880 total: root.querySelector('[data-rt-co-total]'), 881 form: root.querySelector('[data-rt-co-form]'), 882 address: root.querySelector('[data-rt-co-address]'), 883 msg: root.querySelector('[data-rt-co-msg]'), 884 pay: root.querySelector('[data-rt-co-pay]'), 885 done: root.querySelector('[data-rt-co-done]'), 886 receipt: root.querySelector('[data-rt-co-receipt]') 887 }; 888 889 var quote = null; 890 var card = null; 891 var cardReady = null; 892 var payments = null; 893 var wallets = { request: null, apple: null, google: null, wrap: root.querySelector('[data-rt-co-wallets]') }; 894 var quoteTimer = null; 895 896 function message(text, isError) { 897 el.msg.textContent = text || ''; 898 el.msg.classList.toggle('is-error', !!isError); 899 } 900 901 function render() { 902 var items = loadCart(); 903 renderBars(); 904 if (!items.length) { 905 el.empty.hidden = false;
906 el.main.hidden = true; 907 return; 908 } 909 el.empty.hidden = true; 910 el.main.hidden = false; 911 912 el.lines.innerHTML = ''; 913 items.forEach(function (it) { 914 var key = lineKey(it.variation_id, it.modifiers); 915 var li = document.createElement('li'); 916 li.className = 'rt-store-co-line'; 917 li.setAttribute('data-line-key', key); 918 919 var name = document.createElement('div'); 920 name.className = 'rt-store-co-name'; 921 name.appendChild(document.createTextNode(it.name + (it.variation_name ? ' (' + it.variation_name + ')' : ''))); 922 if (it.modifiers.length) { 923 var mods = document.createElement('div'); 924 mods.className = 'rt-store-co-mods'; 925 mods.textContent = it.modifiers.map(function (m) { 926 var label = m.text !== undefined ? m.name + ': ' + m.text : m.name; 927 return label + (m.price_cents > 0 ? ' (+' + fmt(m.price_cents) + ')' : ''); 928 }).join(', '); 929 name.appendChild(mods); 930 } 931 932 var qty = document.createElement('span'); 933 qty.className = 'rt-store-co-qty'; 934 qty.appendChild(qtyButton('-', key, 'Fewer')); 935 var num = document.createElement('span'); 936 num.className = 'rt-store-co-qtynum'; 937 num.textContent = it.qty; 938 qty.appendChild(num); 939 qty.appendChild(qtyButton('+', key, 'More')); 940 941 var price = document.createElement('span'); 942 price.className = 'rt-store-co-price'; 943 price.textContent = fmt(unitCents(it) * it.qty); 944 945 var remove = document.createElement('button'); 946 remove.type = 'button'; 947 remove.className = 'rt-store-co-remove'; 948 remove.setAttribute('data-remove', key); 949 remove.setAttribute('aria-label', 'Remove ' + it.name); 950 remove.textContent = 'Remove'; 951 952 li.appendChild(name); 953 li.appendChild(qty); 954 li.appendChild(price); 955 li.appendChild(remove); 956 el.lines.appendChild(li); 957 }); 958 959 updateTotals(); 960 scheduleQuote(); 961 } 962 963 function qtyButton(sign, key, label) { 964 var btn = document.createElement('button'); 965 btn.type = 'button'; 966 btn.className = 'rt-store-co-qtybtn'; 967 btn.setAttribute('data-qty', sign); 968 btn.setAttribute('data-line-key', key); 969 btn.setAttribute('aria-label', label); 970 btn.textContent = sign; 971 return btn; 972 } 973 974 // The fee line exists only when the server's quote carries one: the cart 975 // bar keeps showing the item subtotal everywhere, and the fee appears at 976 // checkout the way tax does. 977 // A cart of nothing but donations and fees has nothing to hand over, so 978 // the pickup-or-delivery question comes off the form and pickup is sent 979 // as the quiet default the server already expects. 980 function updateFulfillment() { 981 var box = root.querySelector('.rt-store-co-fulfill'); 982 if (!box) { return; } 983 var only = !!(quote && quote.payment_only); 984 box.hidden = only; 985 if (only) { 986 var pick = box.querySelector('input[name="fulfillment"][value="pickup"]'); 987 if (pick) { 988 pick.checked = true; 989 } 990 box.querySelectorAll('[required]').forEach(function (f) { f.required = false; }); 991 } 992 } 993 994 function updateTotals() { 995 updateFulfillment(); 996 var tax = quote && quote.tax ? quote.tax : null; 997 var fee = quote && quote.fee ? quote.fee : null; 998 var total = quote ? quote.total_cents : cartTotal(loadCart()); 999 while (el.total.firstChild) { el.total.removeChild(el.total.firstChild); } 1000 if (tax || fee) { 1001 el.total.appendChild(totalRow('Subtotal: ' + fmt(quote.subtotal_cents), true)); 1002 if (tax) { 1003 el.total.appendChild(totalRow(tax.label + ': ' + fmt(tax.cents), true)); 1004 } 1005 if (fee) { 1006 el.total.appendChild(totalRow(fee.label + ': ' + fmt(fee.cents), true)); 1007 } 1008 el.total.appendChild(totalRow('Total: ' + fmt(total), false)); 1009 } else { 1010 el.total.textContent = 'Total: ' + fmt(total); 1011 } 1012 el.pay.textContent = 'Pay ' + fmt(total); 1013 syncWalletTotal(); 1014 } 1015 1016 function totalRow(text, isSub) { 1017 var row = document.createElement('span'); 1018 row.className = 'rt-store-co-totrow' + (isSub ? ' is-sub' : ''); 1019 row.textContent = text; 1020 return row; 1021 } 1022 1023 el.lines.addEventListener('click', function (e) { 1024 var target = e.target; 1025 var items; 1026 if (target.hasAttribute('data-remove')) { 1027 items = loadCart().filter(function (it) { 1028 return lineKey(it.variation_id, it.modifiers) !== target.getAttribute('data-remove'); 1029 }); 1030 quote = null; 1031 saveCart(items); 1032 render(); 1033 return; 1034 } 1035 if (target.hasAttribute('data-qty')) { 1036 var key = target.getAttribute('data-line-key'); 1037 var delta = target.getAttribute('data-qty') === '+' ? 1 : -1; 1038 items = loadCart(); 1039 for (var i = 0; i < items.length; i++) { 1040 if (lineKey(items[i].variation_id, items[i].modifiers) === key) { 1041 items[i].qty = Math.max(0, Math.min(20, items[i].qty + delta)); 1042 } 1043 } 1044 items = items.filter(function (it) { return it.qty > 0; }); 1045 quote = null; 1046 saveCart(items); 1047 render(); 1048 } 1049 }); 1050 1051 var paying = false;
1052 1053 /** 1054 * Everything the buyer must type or pick before Pay makes sense: 1055 * name, a well-formed email, a fulfillment choice, and an address 1056 * once that choice is delivery. Square's card field validates itself 1057 * at tokenize time, so it stays out of this check. 1058 */ 1059 function formValid() { 1060 var f = el.form.elements; 1061 if (!f.name.value.trim()) { return false; } 1062 if (!f.email.value.trim() || !f.email.checkValidity()) { return false; } 1063 var ftype = f.fulfillment ? f.fulfillment.value : ''; 1064 if (!ftype) { return false; } 1065 if ('delivery' === ftype && !f.address.value.trim()) { return false; } 1066 return true; 1067 } 1068 1069 /** Pay waits, unclickable, until the form above it is whole. A tooltip 1070 * on the disabled button tells the buyer why it is waiting. */ 1071 function refreshPayState() { 1072 el.pay.disabled = paying || !formValid(); 1073 if (el.pay.disabled && !paying) { 1074 el.pay.setAttribute('data-tip', 'Fill in the required fields above first.'); 1075 } else { 1076 el.pay.removeAttribute('data-tip'); 1077 } 1078 if (wallets.wrap) { 1079 wallets.wrap.classList.toggle('is-blocked', el.pay.disabled); 1080 } 1081 } 1082 1083 // The delivery address only exists once delivery is the choice. 1084 el.form.addEventListener('change', function (e) { 1085 if (e.target && e.target.name === 'fulfillment' && el.address) { 1086 var delivery = el.form.elements.fulfillment.value === 'delivery'; 1087 el.address.hidden = !delivery; 1088 el.form.elements.address.required = delivery; 1089 } 1090 refreshPayState(); 1091 }); 1092 el.form.addEventListener('input', refreshPayState); 1093 refreshPayState(); 1094 1095 function scheduleQuote() { 1096 window.clearTimeout(quoteTimer); 1097 quoteTimer = window.setTimeout(requestQuote, 350); 1098 } 1099 1100 function requestQuote() { 1101 var items = loadCart(); 1102 if (!items.length) { return; } 1103 postJson('quote', { items: itemsPayload() }).then(function (res) { 1104 if (!res.ok) { quoteError(res.body); return; } 1105 quote = res.body; 1106 // Server truth over stored display prices, base and modifiers both. 1107 var byKey = {}; 1108 quote.lines.forEach(function (line) { 1109 byKey[lineKey(line.variation_id, line.modifiers)] = line; 1110 }); 1111 var changed = false; 1112 items.forEach(function (it) { 1113 var line = byKey[lineKey(it.variation_id, it.modifiers)]; 1114 if (!line) { return; } 1115 if (line.unit_cents !== it.price_cents) { 1116 it.price_cents = line.unit_cents; 1117 changed = true; 1118 } 1119 var srvMods = {}; 1120 (line.modifiers || []).forEach(function (m) { srvMods[m.id] = m; }); 1121 it.modifiers.forEach(function (m) { 1122 var srv = srvMods[m.id]; 1123 if (srv && (srv.price_cents !== m.price_cents || srv.name !== m.name)) { 1124 m.price_cents = srv.price_cents; 1125 m.name = srv.name; 1126 changed = true; 1127 } 1128 }); 1129 }); 1130 if (changed) { 1131 saveCart(items); 1132 render(); 1133 return; 1134 } 1135 updateTotals(); 1136 }).catch(function () { 1137 message('We could not confirm prices just now. Check your connection and refresh.', true); 1138 }); 1139 } 1140 1141 function quoteError(body) { 1142 var code = body && body.code; 1143 var vid = body && body.data && body.data.variation_id; 1144 if (vid && code === 'rt_store_needs_options') { 1145 // A line added before a personalization field became required: 1146 // take it out so the buyer re-adds it with the field filled in. 1147 saveCart(loadCart().filter(function (it) { return it.variation_id !== vid; })); 1148 quote = null; 1149 render(); 1150 message((body.message || 'One item needed personalization details.') + ' We took it out of your cart; add it again from its product page to fill them in.', true); 1151 return; 1152 } 1153 if (vid && (code === 'rt_store_unknown_item' || code === 'rt_store_sold_out')) { 1154 // The store no longer sells that one: take it out and re-price the rest. 1155 saveCart(loadCart().filter(function (it) { return it.variation_id !== vid; })); 1156 quote = null; 1157 render(); 1158 message((body.message || 'One item is no longer available.') + ' We took it out of your cart.', true); 1159 return; 1160 } 1161 message((body && body.message) || 'Something went wrong checking your cart. Please refresh.', true); 1162 } 1163 1164 /* ---- Square Web Payments SDK ---- */ 1165 1166 function loadSdk() { 1167 if (window.Square) { return Promise.resolve(); } 1168 return new Promise(function (resolve, reject) { 1169 var script = document.createElement('script'); 1170 script.src = cfg.env === 'production' 1171 ? 'https://web.squarecdn.com/v1/square.js' 1172 : 'https://sandbox.web.squarecdn.com/v1/square.js'; 1173 script.onload = resolve; 1174 script.onerror = function () { reject(new Error('sdk load failed')); }; 1175 document.head.appendChild(script); 1176 }); 1177 } 1178 1179 function initCard() { 1180 cardReady = loadSdk().then(function () { 1181 payments = window.Square.payments(cfg.application_id, cfg.location_id); 1182 return payments.card(); 1183 }).then(function (c) { 1184 card = c; 1185 return card.attach('#rt-store-card'); 1186 }).then(function () { 1187 initWallets(); 1188 }).catch(function () { 1189 message('The card form could not load. Refresh the page to try again.', true); 1190 }); 1191 } 1192 1193 /* The amount the wallet sheet shows: the same total the page shows. */ 1194 function walletAmount() { 1195 var total = quote ? quote.total_cents : cartTotal(loadCart()); 1196 return (Math.max(0, total) / 100).toFixed(2); 1197 } 1198 1199 function syncWalletTotal() { 1200 if (!wallets.request || typeof wallets.request.update !== 'function') { return; } 1201 try { 1202 wallets.request.update({ total: { amount: walletAmount(), label: cfg.wallet_label || 'Total' } }); 1203 } catch (err) { } 1204 } 1205 1206 /* 1207 * Apple Pay and Google Pay (FURIOPS-170). Safari on a phone will not 1208 * autofill a saved card into Square's frame, so the wallet button is 1209 * the one-tap path. Each wallet appears only where its SDK call 1210 * succeeds; nothing shows on a browser without one. A wallet token 1211 * takes the exact route a card token does. 1212 */ 1213 function initWallets() { 1214 if (!payments || !wallets.wrap) { return; } 1215 var appleBtn = wallets.wrap.querySelector('#rt-store-apple-pay'); 1216 var googleBox = wallets.wrap.querySelector('#rt-store-google-pay'); 1217 try { 1218 wallets.request = payments.paymentRequest({ 1219 countryCode: 'US', 1220 currencyCode: 'USD', 1221 total: { amount: walletAmount(), label: cfg.wallet_label || 'Total' } 1222 }); 1223 } catch (err) { 1224 return; 1225 }
1226 if (appleBtn && window.ApplePaySession) { 1227 payments.applePay(wallets.request).then(function (ap) { 1228 wallets.apple = ap; 1229 appleBtn.hidden = false; 1230 wallets.wrap.hidden = false; 1231 appleBtn.addEventListener('click', function (e) { 1232 e.preventDefault(); 1233 payWith(ap, 'Apple Pay'); 1234 }); 1235 }).catch(function () { }); 1236 } 1237 if (googleBox && cfg.env === 'production') { 1238 payments.googlePay(wallets.request).then(function (gp) { 1239 return gp.attach('#rt-store-google-pay', { buttonColor: 'black', buttonSizeMode: 'fill', buttonType: 'long' }).then(function () { 1240 wallets.google = gp; 1241 googleBox.hidden = false; 1242 wallets.wrap.hidden = false; 1243 googleBox.addEventListener('click', function (e) { 1244 e.preventDefault(); 1245 payWith(gp, 'Google Pay'); 1246 }); 1247 }); 1248 }).catch(function () { }); 1249 } 1250 refreshPayState(); 1251 } 1252 1253 el.form.addEventListener('submit', function (e) { 1254 e.preventDefault(); 1255 payWith(card, 'card'); 1256 }); 1257 1258 /* One pay path for the card and both wallets: validate, tokenize, post. */ 1259 function payWith(method, kind) { 1260 if (paying) { return; } 1261 if (!method || !quote) { 1262 message('One moment, the checkout is still getting ready. Try again in a second.', true); 1263 return; 1264 } 1265 var name = el.form.elements.name.value.trim(); 1266 var email = el.form.elements.email.value.trim(); 1267 var note = el.form.elements.note.value.trim(); 1268 if (!name || !email) { 1269 message('We need your name and email for your receipt.', true); 1270 return; 1271 } 1272 var ftype = el.form.elements.fulfillment ? el.form.elements.fulfillment.value : 'pickup'; 1273 if (!ftype) { 1274 message('Choose pickup or delivery so we know how to get your order to you.', true); 1275 return; 1276 } 1277 var address = ''; 1278 if (ftype === 'delivery') { 1279 address = el.form.elements.address.value.trim(); 1280 if (!address) { 1281 message('We need a delivery address for contactless delivery.', true); 1282 return; 1283 } 1284 } 1285 1286 var restLabel = el.pay.textContent; 1287 paying = true; 1288 el.pay.disabled = true; 1289 el.pay.textContent = 'Processing...'; 1290 message(''); 1291 1292 method.tokenize().then(function (result) { 1293 if (result.status !== 'OK') { 1294 if (kind !== 'card' && result.status === 'Cancel') { 1295 throw { friendly: '' }; 1296 } 1297 var detail = (result.errors || []).map(function (err) { return err.message; }).join(' '); 1298 throw { friendly: detail || (kind === 'card' ? 'That card could not be verified. Check the number and try again.' : kind + ' did not complete. You can try again or pay with a card.') }; 1299 } 1300 return postJson('pay', { 1301 items: itemsPayload(), 1302 buyer: { name: name, email: email, note: note }, 1303 fulfillment: { type: ftype, address: address }, 1304 source_id: result.token, 1305 idempotency_key: payKey() 1306 }).catch(function () { 1307 // The request itself died: keep the idempotency key so a 1308 // retry cannot become a second charge. 1309 throw { friendly: 'The connection dropped while paying. Try again; the same order cannot be charged twice.', keepKey: true }; 1310 }); 1311 }).then(function (res) { 1312 if (!res) { return; } 1313 if (!res.ok) { 1314 // Square answered and said no: that attempt is over, the 1315 // next one is a new payment with a new key. 1316 clearPayKey(); 1317 throw { friendly: (res.body && res.body.message) || 'The payment did not go through and nothing was charged. Please try again.' }; 1318 } 1319 clearPayKey(); 1320 // A product's own thank-you (a sponsorship's "what happens next") 1321 // replaces the store-wide one when the paid cart carried it. 1322 try { 1323 var thanksMap = JSON.parse(el.done.getAttribute('data-rt-thanks') || '{}'); 1324 var paidItems = loadCart(); 1325 var custom = ''; 1326 for (var ti = 0; ti < paidItems.length && !custom; ti++) { 1327 if (thanksMap[paidItems[ti].variation_id]) { custom = thanksMap[paidItems[ti].variation_id]; } 1328 } 1329 var customEl = el.done.querySelector('[data-rt-co-thanks-custom]'); 1330 var defaultEl = el.done.querySelector('[data-rt-co-thanks-default]'); 1331 if (custom && customEl && defaultEl) { 1332 customEl.textContent = '';
1333 custom.split(/\n\s*\n/).forEach(function (para) { 1334 var p = document.createElement('p'); 1335 p.textContent = para.trim(); 1336 if (p.textContent) { customEl.appendChild(p); } 1337 }); 1338 customEl.hidden = false; 1339 defaultEl.hidden = true; 1340 } 1341 } catch (err) { } 1342 paidTheme = cartTheme(loadCart()); 1343 try { window.localStorage.removeItem(KEY); } catch (err) { } 1344 renderBars(); 1345 el.main.hidden = true; 1346 el.done.hidden = false; 1347 if (res.body.receipt_url) { 1348 el.receipt.href = res.body.receipt_url; 1349 } else { 1350 el.receipt.hidden = true; 1351 } 1352 // The thank-you replaces a long form, so the page is left 1353 // scrolled down at the footer (Furi, 9-6, after his first 1354 // Apple Pay order). Bring it up, then one last confetti. 1355 settleAtThankYou(); 1356 }).catch(function (err) { 1357 paying = false; 1358 el.pay.textContent = restLabel; 1359 refreshPayState(); 1360 if (err && err.friendly === '') { return; } 1361 message((err && err.friendly) || 'Something went sideways and the payment did not go through. Please try again.', true); 1362 }); 1363 } 1364 1365 /* The theme of the cart that just paid (Pink Out), read before the cart is cleared. */ 1366 var paidTheme = ''; 1367 1368 /* 1369 * Put the thank-you on screen, then the confetti. A single smooth 1370 * scrollIntoView did not hold on the phone (Furi, 9-6: still at the 1371 * footer with confetti falling): the wallet sheet closing and the form 1372 * collapsing both move the scroll position after the fact. So: jump to 1373 * the top now, again on the next frame, and again a little later, and 1374 * only then celebrate. 1375 */ 1376 function settleAtThankYou() { 1377 var go = function () { 1378 try { window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); } catch (err) { window.scrollTo(0, 0); } 1379 try { document.documentElement.scrollTop = 0; document.body.scrollTop = 0; } catch (err) { } 1380 }; 1381 go(); 1382 window.requestAnimationFrame(go); 1383 window.setTimeout(go, 250); 1384 window.setTimeout(go, 900); 1385 window.setTimeout(thankYouConfetti, 450); 1386 } 1387 1388 /* One burst over the thank-you: the paid item's own colors when it has them, else the site's. */ 1389 function thankYouConfetti() { 1390 var cs = window.getComputedStyle(document.documentElement); 1391 var tok = function (name, fb) { var v = (cs.getPropertyValue(name) || '').trim(); return v || fb; }; 1392 var colors = THEME_COLORS[paidTheme] || [tok('--rt-primary', '#b3141c'), tok('--rt-secondary', '#1f1f1f'), tok('--rt-neutral', '#c4ccd2'), '#ffffff']; 1393 confettiBurst(colors, null, window.innerWidth > 720 ? 220 : 120, THEME_SHAPES[paidTheme]); 1394 } 1395 1396 render(); 1397 if (loadCart().length) { initCard(); } 1398 } 1399 1400 /* ---- Boot ---- */ 1401 1402 function ready(fn) { 1403 if (document.readyState !== 'loading') { fn(); } else { document.addEventListener('DOMContentLoaded', fn); } 1404 } 1405 1406 ready(function () { 1407 initStore(); 1408 wireSearchableSelects(document); 1409 wireTeacherPickers(document); 1410 initBarMenus(); 1411 syncBarOffset(); 1412 window.addEventListener('resize', syncBarOffset); 1413 renderBars(); 1414 initCheckout(); 1415 }); 1416})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.