PageSourceSearch

https://www.cox-pta.org/wp-content/plugins/rallytrain-engine/assets/js/store-cart.js?ver=2.191.0

js cox-pta.org collected 2026-10-02 01:07:12 UTC 53,237 bytes, 1,416 lines download raw bytes

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.