@runwell/shopify-toolkit 0.18.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/runwell-shopify +10 -1
- package/lib/init.js +13 -2
- package/lib/list.js +22 -9
- package/lib/qa-bundles.js +117 -0
- package/lib/qa.js +147 -13
- package/modules/INDEX.md +14 -5
- package/modules/bundle-builder/README.md +6 -1
- package/modules/bundle-builder/module.json +5 -1
- package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +16 -0
- package/modules/runwell-bundle-system/README.md +35 -0
- package/modules/runwell-bundle-system/admin-metafields.json +55 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +1107 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +444 -0
- package/modules/runwell-bundle-system/module.json +137 -0
- package/modules/runwell-bundle-system/qa/mobile-checklist.md +105 -0
- package/modules/runwell-bundle-system/qa/v1.5-mobile-qa-checklist.md +103 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +59 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +121 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-home-stacks.liquid +77 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +50 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +72 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +117 -0
- package/modules/runwell-bundle-system/settings.json +25 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-accordion.liquid +84 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-grid.liquid +72 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-radio.liquid +77 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker.liquid +71 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-summary.liquid +39 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +70 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-cross-supplier.liquid +18 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +75 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-fomo.liquid +32 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-free-gift.liquid +34 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-multi-product.liquid +86 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-pricing.liquid +30 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-quantity-tiers.liquid +73 -0
- package/package.json +1 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/* runwell-bundle-system: assets/runwell-bundle-system.js.
|
|
2
|
+
|
|
3
|
+
Two responsibilities in v1:
|
|
4
|
+
1. FOMO countdown ticker for the discount-deadline strip on bundle PDPs.
|
|
5
|
+
Cycles every fomo_cycle_days days. No external libs; tabular-nums
|
|
6
|
+
keeps the digits from jumping.
|
|
7
|
+
2. Mode A radio change handler: keeps the ATC button label in sync
|
|
8
|
+
with the selected quantity (purely UX; the form submission carries
|
|
9
|
+
the qty as a hidden input).
|
|
10
|
+
|
|
11
|
+
Surface 4 (cart drawer cross-sell) JS lives here too once BS-6 ships.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
function pad(n) {
|
|
18
|
+
return n < 10 ? '0' + n : '' + n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function startCountdown(el) {
|
|
22
|
+
const cycleDays = parseInt(el.dataset.fomoCycleDays || '30', 10);
|
|
23
|
+
if (!cycleDays || cycleDays <= 0) return;
|
|
24
|
+
|
|
25
|
+
const cycleMs = cycleDays * 24 * 60 * 60 * 1000;
|
|
26
|
+
const epoch = new Date('2026-01-01T00:00:00Z').getTime();
|
|
27
|
+
const timeEl = el.querySelector('[data-runwell-fomo-time]');
|
|
28
|
+
if (!timeEl) return;
|
|
29
|
+
|
|
30
|
+
function tick() {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const elapsedInCycle = (now - epoch) % cycleMs;
|
|
33
|
+
const remainingMs = cycleMs - elapsedInCycle;
|
|
34
|
+
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
|
35
|
+
const hours = Math.floor((remainingMs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
|
36
|
+
const minutes = Math.floor((remainingMs % (60 * 60 * 1000)) / (60 * 1000));
|
|
37
|
+
|
|
38
|
+
if (days > 0) {
|
|
39
|
+
timeEl.textContent = days + 'd ' + pad(hours) + 'h ' + pad(minutes) + 'm';
|
|
40
|
+
} else {
|
|
41
|
+
const seconds = Math.floor((remainingMs % (60 * 1000)) / 1000);
|
|
42
|
+
timeEl.textContent = pad(hours) + 'h ' + pad(minutes) + 'm ' + pad(seconds) + 's';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
tick();
|
|
47
|
+
setInterval(tick, 1000);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function initFomo() {
|
|
51
|
+
document
|
|
52
|
+
.querySelectorAll('[data-runwell-fomo]')
|
|
53
|
+
.forEach(function (el) {
|
|
54
|
+
const mode = el.dataset.fomoMode;
|
|
55
|
+
if (mode === 'discount' || mode === 'both') startCountdown(el);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function initTierSync() {
|
|
60
|
+
document
|
|
61
|
+
.querySelectorAll('.runwell-bundle-system__tiers')
|
|
62
|
+
.forEach(function (fieldset) {
|
|
63
|
+
fieldset.addEventListener('change', function (e) {
|
|
64
|
+
if (!e.target.matches('.runwell-bundle-system__tier-input')) return;
|
|
65
|
+
fieldset
|
|
66
|
+
.querySelectorAll('.runwell-bundle-system__tier')
|
|
67
|
+
.forEach(function (label) {
|
|
68
|
+
const input = label.querySelector('.runwell-bundle-system__tier-input');
|
|
69
|
+
label.classList.toggle('is-selected', input.checked);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function initFilterChip() {
|
|
76
|
+
document
|
|
77
|
+
.querySelectorAll('[data-runwell-bundle-filter-chip]')
|
|
78
|
+
.forEach(function (chip) {
|
|
79
|
+
const section = chip.closest('.runwell-bundle-collection--filter-chip');
|
|
80
|
+
if (!section) return;
|
|
81
|
+
const handlesAttr = section.dataset.bundleHandles || '';
|
|
82
|
+
const bundleHandles = handlesAttr
|
|
83
|
+
.split(',')
|
|
84
|
+
.map(function (h) { return h.trim(); })
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
function applyFilter(active) {
|
|
88
|
+
chip.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
89
|
+
const cards = document.querySelectorAll(
|
|
90
|
+
'.product-grid .product-card, .grid__item .card-wrapper, [data-product-handle]'
|
|
91
|
+
);
|
|
92
|
+
cards.forEach(function (card) {
|
|
93
|
+
const handle =
|
|
94
|
+
card.dataset.productHandle ||
|
|
95
|
+
(card.querySelector('[data-product-handle]') || {}).dataset?.productHandle ||
|
|
96
|
+
'';
|
|
97
|
+
const tags = (card.dataset.tags || '').toLowerCase();
|
|
98
|
+
const isBundle = bundleHandles.indexOf(handle) !== -1 || tags.indexOf('bundle') !== -1;
|
|
99
|
+
card.style.display = active && !isBundle ? 'none' : '';
|
|
100
|
+
});
|
|
101
|
+
const url = new URL(window.location.href);
|
|
102
|
+
if (active) {
|
|
103
|
+
url.searchParams.set('filter', 'bundle');
|
|
104
|
+
} else {
|
|
105
|
+
url.searchParams.delete('filter');
|
|
106
|
+
}
|
|
107
|
+
window.history.replaceState({}, '', url);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
chip.addEventListener('click', function () {
|
|
111
|
+
const active = chip.getAttribute('aria-pressed') === 'true';
|
|
112
|
+
applyFilter(!active);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const initial = new URL(window.location.href).searchParams.get('filter') === 'bundle';
|
|
116
|
+
if (initial) applyFilter(true);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------- Surface 4: cart drawer bundle cross-sell ---------- */
|
|
121
|
+
|
|
122
|
+
function readBundleIndex() {
|
|
123
|
+
const el = document.querySelector('[data-runwell-bundle-index]');
|
|
124
|
+
if (!el) return null;
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(el.textContent || '{}');
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function findBundleMatch(cartHandles, bundleIndex) {
|
|
133
|
+
if (!bundleIndex || !bundleIndex.products || !bundleIndex.bundles) return null;
|
|
134
|
+
const candidates = new Set();
|
|
135
|
+
cartHandles.forEach(function (h) {
|
|
136
|
+
(bundleIndex.products[h] || []).forEach(function (b) {
|
|
137
|
+
candidates.add(b);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
let best = null;
|
|
141
|
+
candidates.forEach(function (bundleHandle) {
|
|
142
|
+
const bundle = bundleIndex.bundles[bundleHandle];
|
|
143
|
+
if (!bundle || !bundle.components) return;
|
|
144
|
+
const compHandles = Object.keys(bundle.components);
|
|
145
|
+
const inCart = compHandles.filter(function (h) {
|
|
146
|
+
return cartHandles.indexOf(h) !== -1;
|
|
147
|
+
});
|
|
148
|
+
if (inCart.length >= 1 && inCart.length < compHandles.length) {
|
|
149
|
+
if (!best || inCart.length > best.matchCount) {
|
|
150
|
+
best = { bundleHandle: bundleHandle, matchCount: inCart.length, total: compHandles.length };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return best;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fetchCart() {
|
|
158
|
+
return fetch('/cart.js', { headers: { Accept: 'application/json' } })
|
|
159
|
+
.then(function (r) { return r.json(); })
|
|
160
|
+
.catch(function () { return { items: [] }; });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function fetchBundleProduct(handle) {
|
|
164
|
+
return fetch('/products/' + handle + '.js', { headers: { Accept: 'application/json' } })
|
|
165
|
+
.then(function (r) { return r.json(); })
|
|
166
|
+
.catch(function () { return null; });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderCard(slot, bundleProduct, eyebrow, cta) {
|
|
170
|
+
if (!bundleProduct) return;
|
|
171
|
+
slot.innerHTML =
|
|
172
|
+
'<p class="runwell-bundle-cart-xsell__eyebrow">' + escapeHtml(eyebrow) + '</p>' +
|
|
173
|
+
'<div class="runwell-bundle-cart-xsell__card">' +
|
|
174
|
+
(bundleProduct.featured_image
|
|
175
|
+
? '<img class="runwell-bundle-cart-xsell__thumb" loading="lazy" width="60" height="60" alt="" src="' + bundleProduct.featured_image + '">'
|
|
176
|
+
: '') +
|
|
177
|
+
'<div class="runwell-bundle-cart-xsell__details">' +
|
|
178
|
+
'<p class="runwell-bundle-cart-xsell__name">' + escapeHtml(bundleProduct.title) + '</p>' +
|
|
179
|
+
'<p class="runwell-bundle-cart-xsell__price">' + formatMoney(bundleProduct.price) + '</p>' +
|
|
180
|
+
'</div>' +
|
|
181
|
+
'<button type="button" class="runwell-bundle-cart-xsell__cta" data-runwell-bundle-cta data-bundle-handle="' + bundleProduct.handle + '" data-bundle-variant-id="' + bundleProduct.variants[0].id + '">' + escapeHtml(cta) + '</button>' +
|
|
182
|
+
'</div>';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function escapeHtml(s) {
|
|
186
|
+
return String(s == null ? '' : s)
|
|
187
|
+
.replace(/&/g, '&')
|
|
188
|
+
.replace(/</g, '<')
|
|
189
|
+
.replace(/>/g, '>')
|
|
190
|
+
.replace(/"/g, '"');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatMoney(cents) {
|
|
194
|
+
return '$' + (cents / 100).toFixed(2);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function addBundleAndRemoveComponents(variantId, componentHandles, cart) {
|
|
198
|
+
const updates = {};
|
|
199
|
+
if (cart && cart.items) {
|
|
200
|
+
cart.items.forEach(function (item) {
|
|
201
|
+
if (componentHandles.indexOf(item.handle) !== -1) {
|
|
202
|
+
updates[item.key] = 0;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return fetch('/cart/add.js', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
209
|
+
body: JSON.stringify({ id: variantId, quantity: 1 })
|
|
210
|
+
})
|
|
211
|
+
.then(function (r) { return r.json(); })
|
|
212
|
+
.then(function () {
|
|
213
|
+
if (Object.keys(updates).length === 0) return null;
|
|
214
|
+
return fetch('/cart/update.js', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
217
|
+
body: JSON.stringify({ updates: updates })
|
|
218
|
+
}).then(function (r) { return r.json(); });
|
|
219
|
+
})
|
|
220
|
+
.then(function () {
|
|
221
|
+
document.dispatchEvent(new CustomEvent('cart:refresh'));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function initCartXsell() {
|
|
226
|
+
const slot = document.querySelector('[data-runwell-bundle-xsell-slot]');
|
|
227
|
+
if (!slot) return;
|
|
228
|
+
const bundleIndex = readBundleIndex();
|
|
229
|
+
if (!bundleIndex) return;
|
|
230
|
+
|
|
231
|
+
function evaluate() {
|
|
232
|
+
fetchCart().then(function (cart) {
|
|
233
|
+
const cartHandles = (cart.items || []).map(function (i) { return i.handle; });
|
|
234
|
+
if (cartHandles.length === 0) {
|
|
235
|
+
slot.innerHTML = '';
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const match = findBundleMatch(cartHandles, bundleIndex);
|
|
239
|
+
if (!match) {
|
|
240
|
+
slot.innerHTML = '';
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
fetchBundleProduct(match.bundleHandle).then(function (bp) {
|
|
244
|
+
if (!bp || !bp.variants || bp.variants.length === 0) {
|
|
245
|
+
slot.innerHTML = '';
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
renderCard(slot, bp, slot.dataset.eyebrow || 'Complete the stack', slot.dataset.cta || 'Add bundle and save');
|
|
249
|
+
document.dispatchEvent(new CustomEvent('runwell:bundle-xsell-active', { detail: { bundleHandle: match.bundleHandle } }));
|
|
250
|
+
|
|
251
|
+
const cta = slot.querySelector('[data-runwell-bundle-cta]');
|
|
252
|
+
if (cta) {
|
|
253
|
+
cta.addEventListener('click', function () {
|
|
254
|
+
const vid = parseInt(cta.dataset.bundleVariantId, 10);
|
|
255
|
+
const handle = cta.dataset.bundleHandle;
|
|
256
|
+
const bundle = bundleIndex.bundles[handle];
|
|
257
|
+
const componentHandles = bundle ? Object.keys(bundle.components) : [];
|
|
258
|
+
cta.disabled = true;
|
|
259
|
+
addBundleAndRemoveComponents(vid, componentHandles, cart).catch(function () {
|
|
260
|
+
cta.disabled = false;
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
evaluate();
|
|
269
|
+
['cart:updated', 'cart:refresh', 'cart:change'].forEach(function (evt) {
|
|
270
|
+
document.addEventListener(evt, evaluate);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ---------- Mode C BYOB ---------- */
|
|
275
|
+
|
|
276
|
+
function parsePricingValue(raw) {
|
|
277
|
+
if (raw == null || raw === '') return null;
|
|
278
|
+
try { return JSON.parse(raw); } catch (e) { return null; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function computeByobPrice(subtotalCents, pricingModel, pricingValue) {
|
|
282
|
+
if (pricingModel === 'fixed_bundle_price' || pricingModel === 'fixed_price') {
|
|
283
|
+
const v = typeof pricingValue === 'object' && pricingValue !== null
|
|
284
|
+
? (pricingValue.amount || pricingValue.price || 0)
|
|
285
|
+
: pricingValue;
|
|
286
|
+
return Math.max(0, parseInt(v, 10) || 0);
|
|
287
|
+
}
|
|
288
|
+
if (pricingModel === 'percent_off_subtotal') {
|
|
289
|
+
const pct = typeof pricingValue === 'object' && pricingValue !== null
|
|
290
|
+
? (pricingValue.pct || pricingValue.percent || 0)
|
|
291
|
+
: pricingValue;
|
|
292
|
+
const discounted = subtotalCents * (1 - (parseFloat(pct) || 0) / 100);
|
|
293
|
+
return Math.max(0, Math.round(discounted));
|
|
294
|
+
}
|
|
295
|
+
if (pricingModel === 'dollar_off_subtotal') {
|
|
296
|
+
const off = typeof pricingValue === 'object' && pricingValue !== null
|
|
297
|
+
? (pricingValue.amount || pricingValue.off || 0)
|
|
298
|
+
: pricingValue;
|
|
299
|
+
return Math.max(0, subtotalCents - (parseInt(off, 10) || 0));
|
|
300
|
+
}
|
|
301
|
+
return subtotalCents;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatByobMoney(cents) {
|
|
305
|
+
return '$' + (cents / 100).toFixed(2);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function initByob() {
|
|
309
|
+
document
|
|
310
|
+
.querySelectorAll('[data-runwell-byob-picker]')
|
|
311
|
+
.forEach(function (picker) {
|
|
312
|
+
const form = picker.closest('form');
|
|
313
|
+
if (!form) return;
|
|
314
|
+
const root = form.parentElement || form;
|
|
315
|
+
const summary = root.querySelector('[data-runwell-byob-summary]');
|
|
316
|
+
const atc = form.querySelector('[data-runwell-byob-atc]');
|
|
317
|
+
|
|
318
|
+
const layout = picker.dataset.byobLayout;
|
|
319
|
+
const min = parseInt(picker.dataset.byobMin, 10) || 0;
|
|
320
|
+
const max = parseInt(picker.dataset.byobMax, 10) || 0;
|
|
321
|
+
const pricingModel = picker.dataset.byobPricingModel;
|
|
322
|
+
const pricingValue = parsePricingValue(picker.dataset.byobPricingValue);
|
|
323
|
+
|
|
324
|
+
function getSelected() {
|
|
325
|
+
return Array.from(picker.querySelectorAll('.runwell-bundle-system__byob-input:checked'))
|
|
326
|
+
.map(function (input) {
|
|
327
|
+
const label = input.closest('.runwell-bundle-system__byob-candidate');
|
|
328
|
+
return {
|
|
329
|
+
handle: label.dataset.handle,
|
|
330
|
+
price: parseInt(label.dataset.price, 10) || 0,
|
|
331
|
+
variantId: label.dataset.variantId,
|
|
332
|
+
category: label.dataset.category || null,
|
|
333
|
+
input: input
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function updateAccordionCounts() {
|
|
339
|
+
if (layout !== 'accordion') return;
|
|
340
|
+
picker.querySelectorAll('.runwell-bundle-system__byob-accordion').forEach(function (acc) {
|
|
341
|
+
const count = acc.querySelectorAll('.runwell-bundle-system__byob-input:checked').length;
|
|
342
|
+
const badge = acc.querySelector('[data-runwell-byob-cat-count]');
|
|
343
|
+
if (badge) badge.textContent = count;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function enforceMax(selected) {
|
|
348
|
+
if (layout === 'radio') return;
|
|
349
|
+
if (selected.length <= max) return;
|
|
350
|
+
const overflow = selected.slice(max);
|
|
351
|
+
overflow.forEach(function (s) {
|
|
352
|
+
if (!s.input.disabled) s.input.checked = false;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function render() {
|
|
357
|
+
let selected = getSelected();
|
|
358
|
+
enforceMax(selected);
|
|
359
|
+
selected = getSelected();
|
|
360
|
+
|
|
361
|
+
const count = selected.length;
|
|
362
|
+
const subtotal = selected.reduce(function (acc, s) { return acc + s.price; }, 0);
|
|
363
|
+
const total = computeByobPrice(subtotal, pricingModel, pricingValue);
|
|
364
|
+
const savings = Math.max(0, subtotal - total);
|
|
365
|
+
|
|
366
|
+
if (summary) {
|
|
367
|
+
const selEl = summary.querySelector('[data-runwell-byob-selected]');
|
|
368
|
+
const totalEl = summary.querySelector('[data-runwell-byob-total]');
|
|
369
|
+
const subEl = summary.querySelector('[data-runwell-byob-subtotal]');
|
|
370
|
+
const subAmt = summary.querySelector('[data-runwell-byob-subtotal-amount]');
|
|
371
|
+
const savEl = summary.querySelector('[data-runwell-byob-savings]');
|
|
372
|
+
const savAmt = summary.querySelector('[data-runwell-byob-savings-amount]');
|
|
373
|
+
const helper = summary.querySelector('[data-runwell-byob-helper]');
|
|
374
|
+
|
|
375
|
+
if (selEl) selEl.textContent = count;
|
|
376
|
+
if (totalEl) totalEl.textContent = formatByobMoney(total);
|
|
377
|
+
const isFixed = pricingModel === 'fixed_bundle_price' || pricingModel === 'fixed_price';
|
|
378
|
+
if (savings > 0 && !isFixed) {
|
|
379
|
+
if (subEl) { subEl.hidden = false; if (subAmt) subAmt.textContent = formatByobMoney(subtotal); }
|
|
380
|
+
if (savEl) { savEl.hidden = false; if (savAmt) savAmt.textContent = formatByobMoney(savings); }
|
|
381
|
+
} else {
|
|
382
|
+
if (subEl) subEl.hidden = true;
|
|
383
|
+
if (savEl) savEl.hidden = true;
|
|
384
|
+
}
|
|
385
|
+
if (helper) helper.style.display = count >= min ? 'none' : '';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (atc) {
|
|
389
|
+
const ok = count >= min && count <= max;
|
|
390
|
+
atc.disabled = !ok;
|
|
391
|
+
}
|
|
392
|
+
updateAccordionCounts();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
picker.addEventListener('change', function (e) {
|
|
396
|
+
if (!e.target.matches('.runwell-bundle-system__byob-input')) return;
|
|
397
|
+
render();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
form.addEventListener('submit', function (e) {
|
|
401
|
+
const selected = getSelected();
|
|
402
|
+
if (selected.length < min || selected.length > max) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
const items = selected.map(function (s) {
|
|
408
|
+
return { id: parseInt(s.variantId, 10), quantity: 1 };
|
|
409
|
+
});
|
|
410
|
+
fetch('/cart/add.js', {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
413
|
+
body: JSON.stringify({ items: items })
|
|
414
|
+
})
|
|
415
|
+
.then(function (r) { return r.json(); })
|
|
416
|
+
.then(function () {
|
|
417
|
+
document.dispatchEvent(new CustomEvent('cart:refresh'));
|
|
418
|
+
window.location.href = '/cart';
|
|
419
|
+
})
|
|
420
|
+
.catch(function () {
|
|
421
|
+
if (atc) atc.disabled = false;
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
render();
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (document.readyState === 'loading') {
|
|
430
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
431
|
+
initFomo();
|
|
432
|
+
initTierSync();
|
|
433
|
+
initFilterChip();
|
|
434
|
+
initCartXsell();
|
|
435
|
+
initByob();
|
|
436
|
+
});
|
|
437
|
+
} else {
|
|
438
|
+
initFomo();
|
|
439
|
+
initTierSync();
|
|
440
|
+
initFilterChip();
|
|
441
|
+
initCartXsell();
|
|
442
|
+
initByob();
|
|
443
|
+
}
|
|
444
|
+
})();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "runwell-bundle-system",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"category": "catalog",
|
|
5
|
+
"source": "runwell",
|
|
6
|
+
"base": "bundle-system",
|
|
7
|
+
"description": "Configurable bundles engine. Single-product quantity tiers (Mode A), curated multi-product (Mode B), mix-and-match (Mode C, v1.5), subscription bundles (Mode D, v2). Reads per-bundle config from product metafields runwell.bundle_*. Sits on top of Shopify's free first-party Bundles app for inventory and cart math. Replaces the legacy bundle-builder module.",
|
|
8
|
+
"replaces": ["bundle-builder"],
|
|
9
|
+
"files": {
|
|
10
|
+
"sections": [
|
|
11
|
+
"sections/runwell-bundle-pdp.liquid",
|
|
12
|
+
"sections/runwell-bundle-pdp-pairs-with.liquid",
|
|
13
|
+
"sections/runwell-bundle-home-stacks.liquid",
|
|
14
|
+
"sections/runwell-bundle-cart-xsell.liquid",
|
|
15
|
+
"sections/runwell-bundle-pdp-banner.liquid",
|
|
16
|
+
"sections/runwell-bundle-collection.liquid"
|
|
17
|
+
],
|
|
18
|
+
"snippets": [
|
|
19
|
+
"snippets/runwell-bundle-card.liquid",
|
|
20
|
+
"snippets/runwell-bundle-quantity-tiers.liquid",
|
|
21
|
+
"snippets/runwell-bundle-multi-product.liquid",
|
|
22
|
+
"snippets/runwell-bundle-pricing.liquid",
|
|
23
|
+
"snippets/runwell-bundle-cross-supplier.liquid",
|
|
24
|
+
"snippets/runwell-bundle-fomo.liquid",
|
|
25
|
+
"snippets/runwell-bundle-free-gift.liquid",
|
|
26
|
+
"snippets/runwell-bundle-data.liquid",
|
|
27
|
+
"snippets/runwell-bundle-byob-picker.liquid",
|
|
28
|
+
"snippets/runwell-bundle-byob-picker-grid.liquid",
|
|
29
|
+
"snippets/runwell-bundle-byob-picker-accordion.liquid",
|
|
30
|
+
"snippets/runwell-bundle-byob-picker-radio.liquid",
|
|
31
|
+
"snippets/runwell-bundle-byob-summary.liquid"
|
|
32
|
+
],
|
|
33
|
+
"assets": [
|
|
34
|
+
"assets/runwell-bundle-system.css",
|
|
35
|
+
"assets/runwell-bundle-system.js"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"config": {
|
|
39
|
+
"schema": {
|
|
40
|
+
"surface_1_collection_page_enabled": { "type": "boolean", "default": true, "label": "Enable /bundles collection page" },
|
|
41
|
+
"surface_2_pdp_pairs_with_enabled": { "type": "boolean", "default": true, "label": "Enable PDP 'Pairs well with' widget" },
|
|
42
|
+
"surface_3_home_stacks_enabled": { "type": "boolean", "default": true, "label": "Enable home page curated stacks strip" },
|
|
43
|
+
"surface_4_cart_drawer_xsell_enabled": { "type": "boolean", "default": true, "label": "Enable cart drawer bundle cross-sell" },
|
|
44
|
+
"surface_5_pdp_banner_enabled": { "type": "boolean", "default": true, "label": "Enable PDP bundle banner" },
|
|
45
|
+
"surface_6_collection_filter_enabled": { "type": "boolean", "default": false, "label": "Enable bundles filter on collection pages" },
|
|
46
|
+
"surface_2_eyebrow": { "type": "string", "default": "Pairs well with", "label": "Surface 2 eyebrow copy" },
|
|
47
|
+
"surface_2_heading": { "type": "string", "default": "Complete the routine", "label": "Surface 2 heading copy" },
|
|
48
|
+
"surface_3_eyebrow": { "type": "string", "default": "Editor's picks", "label": "Surface 3 eyebrow copy" },
|
|
49
|
+
"surface_3_heading": { "type": "string", "default": "Curated stacks", "label": "Surface 3 heading copy" },
|
|
50
|
+
"surface_4_eyebrow": { "type": "string", "default": "Complete the stack", "label": "Surface 4 eyebrow copy" },
|
|
51
|
+
"surface_4_cta": { "type": "string", "default": "Add bundle and save", "label": "Surface 4 CTA label" },
|
|
52
|
+
"surface_5_copy_template": { "type": "string", "default": "Save {savings_pct}% when bundled", "label": "Surface 5 copy template (use {savings_pct} or {savings_amount})" },
|
|
53
|
+
"cross_supplier_disclosure": { "type": "string", "default": "This bundle ships in {n} packages.", "label": "Cross-supplier disclosure text" },
|
|
54
|
+
"home_strip_position": { "type": "number", "default": 3, "label": "Position of home page bundle strip in templates/index.json" }
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"admin_steps": [
|
|
58
|
+
{
|
|
59
|
+
"id": "install-shopify-bundles",
|
|
60
|
+
"label": "Install Shopify's free Bundles app",
|
|
61
|
+
"url": "https://apps.shopify.com/shopify-bundles",
|
|
62
|
+
"summary": "Install the Bundles app made by Shopify (free, first-party). Required for bundle inventory deduction and cart line-item math. The runwell-bundle-system module sits on top of this; it does not replace it."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "define-bundle-metafields",
|
|
66
|
+
"label": "Define the runwell.bundle_* product metafield definitions",
|
|
67
|
+
"url": "https://admin.shopify.com/store/{store_handle}/settings/custom_data/products/metafields",
|
|
68
|
+
"summary": "Settings > Custom data > Products > Add definition. See modules/runwell-bundle-system/admin-metafields.json for the full list of 19 definitions (namespace, key, type, validations, defaults). Mirrors spec.md section 2.1."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"id": "create-bundle-index-metaobject",
|
|
72
|
+
"label": "Create the bundle_index shop metaobject definition",
|
|
73
|
+
"url": "https://admin.shopify.com/store/{store_handle}/settings/custom_data/metaobjects",
|
|
74
|
+
"summary": "Settings > Custom data > Metaobjects > Add definition. Type: bundle_index. Field: entries (JSON). One instance per shop. Used by the PDP 'Pairs well with' widget to avoid N+1 reads. See modules/runwell-bundle-system/admin-metafields.json -> shop_metaobjects."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "create-first-bundle-product",
|
|
78
|
+
"label": "Create your first bundle product",
|
|
79
|
+
"url": "https://admin.shopify.com/store/{store_handle}/products/new",
|
|
80
|
+
"summary": "Use the Shopify Bundles app to create the bundle (Apps > Bundles > Create). The app creates a bundle product. SKU convention: BUNDLE-<handle>. Then open the bundle product, scroll to Metafields, and fill the runwell.bundle_* fields per the bundle's mode."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "configure-byob-mode",
|
|
84
|
+
"label": "Configure a BYOB (Mode C) bundle product",
|
|
85
|
+
"url": "https://admin.shopify.com/store/{store_handle}/products",
|
|
86
|
+
"summary": "Mode C only. Set bundle_mode = 'byob'. Required: bundle_byob_candidates (list of product references), bundle_byob_min_picks, bundle_byob_max_picks, bundle_pricing_model (fixed_bundle_price / percent_off_subtotal / dollar_off_subtotal), bundle_pricing_value. Optional: bundle_byob_layout (grid default, accordion, radio), bundle_byob_categories (JSON), bundle_byob_required_handles. Install a Shopify Functions discount that mirrors the pricing model so the savings apply at checkout."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "configure-quantity-tier-discount-function",
|
|
90
|
+
"label": "Create a Shopify Functions discount for quantity-tier mode (Mode A only)",
|
|
91
|
+
"url": "https://shopify.dev/docs/apps/build/discounts/sample-apps",
|
|
92
|
+
"summary": "Build a Shopify Function (or use an existing Functions app like 'Volume Discount Builder') that reads runwell.bundle_quantity_tiers per line item and applies the matching discount. Required only for Mode A (single-product quantity tiers). Mode B uses Shopify Bundles app native pricing."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "configure-free-gift-discount",
|
|
96
|
+
"label": "Create the 100% off discount on the gift product (free-gift bundles only)",
|
|
97
|
+
"url": "https://admin.shopify.com/store/{store_handle}/discounts/new",
|
|
98
|
+
"summary": "Per gift-with-purchase module convention. Discount type: Amount off products. Automatic. 100%. Applies to: the gift product (matching bundle_free_gift_handle). Minimum requirement: bundle in cart. So the gift line lands free at checkout."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"id": "create-bundles-collection-page-template",
|
|
102
|
+
"label": "Create the /bundles collection page template (Surface 1)",
|
|
103
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
104
|
+
"summary": "Online Store > Themes > Customize > Templates > Add template. Type: page. Name: bundles. Add the runwell-bundle-collection section. Then create a Page in Online Store > Pages > Add page. Title: Bundles. URL handle: bundles. Template: page.bundles."
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "add-home-stacks-section",
|
|
108
|
+
"label": "Add the curated stacks section to the home page (Surface 3)",
|
|
109
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
110
|
+
"summary": "Theme customizer > Home page > Add section > runwell-bundle-home-stacks. Position per home_strip_position config (default: 3rd section)."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "add-pdp-pairs-with-section",
|
|
114
|
+
"label": "Add the PDP 'Pairs well with' widget (Surface 2)",
|
|
115
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
116
|
+
"summary": "Theme customizer > Products > Default product > Add section > runwell-bundle-pdp-pairs-with. The section auto-shows only on PDPs of products that appear in at least one bundle."
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "add-pdp-banner-section",
|
|
120
|
+
"label": "Add the PDP bundle banner (Surface 5)",
|
|
121
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
122
|
+
"summary": "Theme customizer > Products > Default product > Add block (inside main-product) > runwell-bundle-pdp-banner. Position near the buy box. Section auto-shows only on PDPs of products that appear in at least one bundle."
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"id": "verify-cart-drawer-coordination",
|
|
126
|
+
"label": "Verify cart drawer bundle cross-sell coordinates with cart-cross-sell (Surface 4)",
|
|
127
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
128
|
+
"summary": "Theme customizer > Cart drawer. Confirm the runwell-cart-bundle-xsell snippet renders before runwell-cart-xsell. The bundle xsell suppresses the single-product xsell when a bundle match is found; falls back when no match. No merchant action beyond install order."
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "rebuild-bundle-index",
|
|
132
|
+
"label": "Rebuild the bundle_index metaobject after creating or editing bundles",
|
|
133
|
+
"url": "https://admin.shopify.com/store/{store_handle}/apps",
|
|
134
|
+
"summary": "Run runwell-shopify rebuild-bundle-index from the toolkit CLI, or trigger the Shopify Flow workflow shipped with this module. Updates the reverse mapping used by Surface 2. Re-run any time a bundle's components change."
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|