@runwell/shopify-toolkit 0.18.0 → 0.21.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/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 +46 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +861 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +287 -0
- package/modules/runwell-bundle-system/module.json +126 -0
- package/modules/runwell-bundle-system/qa/mobile-checklist.md +105 -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 +105 -0
- package/modules/runwell-bundle-system/settings.json +25 -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 +67 -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,287 @@
|
|
|
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
|
+
if (document.readyState === 'loading') {
|
|
275
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
276
|
+
initFomo();
|
|
277
|
+
initTierSync();
|
|
278
|
+
initFilterChip();
|
|
279
|
+
initCartXsell();
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
initFomo();
|
|
283
|
+
initTierSync();
|
|
284
|
+
initFilterChip();
|
|
285
|
+
initCartXsell();
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "runwell-bundle-system",
|
|
3
|
+
"version": "0.1.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
|
+
],
|
|
28
|
+
"assets": [
|
|
29
|
+
"assets/runwell-bundle-system.css",
|
|
30
|
+
"assets/runwell-bundle-system.js"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"config": {
|
|
34
|
+
"schema": {
|
|
35
|
+
"surface_1_collection_page_enabled": { "type": "boolean", "default": true, "label": "Enable /bundles collection page" },
|
|
36
|
+
"surface_2_pdp_pairs_with_enabled": { "type": "boolean", "default": true, "label": "Enable PDP 'Pairs well with' widget" },
|
|
37
|
+
"surface_3_home_stacks_enabled": { "type": "boolean", "default": true, "label": "Enable home page curated stacks strip" },
|
|
38
|
+
"surface_4_cart_drawer_xsell_enabled": { "type": "boolean", "default": true, "label": "Enable cart drawer bundle cross-sell" },
|
|
39
|
+
"surface_5_pdp_banner_enabled": { "type": "boolean", "default": true, "label": "Enable PDP bundle banner" },
|
|
40
|
+
"surface_6_collection_filter_enabled": { "type": "boolean", "default": false, "label": "Enable bundles filter on collection pages" },
|
|
41
|
+
"surface_2_eyebrow": { "type": "string", "default": "Pairs well with", "label": "Surface 2 eyebrow copy" },
|
|
42
|
+
"surface_2_heading": { "type": "string", "default": "Complete the routine", "label": "Surface 2 heading copy" },
|
|
43
|
+
"surface_3_eyebrow": { "type": "string", "default": "Editor's picks", "label": "Surface 3 eyebrow copy" },
|
|
44
|
+
"surface_3_heading": { "type": "string", "default": "Curated stacks", "label": "Surface 3 heading copy" },
|
|
45
|
+
"surface_4_eyebrow": { "type": "string", "default": "Complete the stack", "label": "Surface 4 eyebrow copy" },
|
|
46
|
+
"surface_4_cta": { "type": "string", "default": "Add bundle and save", "label": "Surface 4 CTA label" },
|
|
47
|
+
"surface_5_copy_template": { "type": "string", "default": "Save {savings_pct}% when bundled", "label": "Surface 5 copy template (use {savings_pct} or {savings_amount})" },
|
|
48
|
+
"cross_supplier_disclosure": { "type": "string", "default": "This bundle ships in {n} packages.", "label": "Cross-supplier disclosure text" },
|
|
49
|
+
"home_strip_position": { "type": "number", "default": 3, "label": "Position of home page bundle strip in templates/index.json" }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"admin_steps": [
|
|
53
|
+
{
|
|
54
|
+
"id": "install-shopify-bundles",
|
|
55
|
+
"label": "Install Shopify's free Bundles app",
|
|
56
|
+
"url": "https://apps.shopify.com/shopify-bundles",
|
|
57
|
+
"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."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "define-bundle-metafields",
|
|
61
|
+
"label": "Define the runwell.bundle_* product metafield definitions",
|
|
62
|
+
"url": "https://admin.shopify.com/store/{store_handle}/settings/custom_data/products/metafields",
|
|
63
|
+
"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."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "create-bundle-index-metaobject",
|
|
67
|
+
"label": "Create the bundle_index shop metaobject definition",
|
|
68
|
+
"url": "https://admin.shopify.com/store/{store_handle}/settings/custom_data/metaobjects",
|
|
69
|
+
"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."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "create-first-bundle-product",
|
|
73
|
+
"label": "Create your first bundle product",
|
|
74
|
+
"url": "https://admin.shopify.com/store/{store_handle}/products/new",
|
|
75
|
+
"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."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "configure-quantity-tier-discount-function",
|
|
79
|
+
"label": "Create a Shopify Functions discount for quantity-tier mode (Mode A only)",
|
|
80
|
+
"url": "https://shopify.dev/docs/apps/build/discounts/sample-apps",
|
|
81
|
+
"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."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"id": "configure-free-gift-discount",
|
|
85
|
+
"label": "Create the 100% off discount on the gift product (free-gift bundles only)",
|
|
86
|
+
"url": "https://admin.shopify.com/store/{store_handle}/discounts/new",
|
|
87
|
+
"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."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "create-bundles-collection-page-template",
|
|
91
|
+
"label": "Create the /bundles collection page template (Surface 1)",
|
|
92
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
93
|
+
"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."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "add-home-stacks-section",
|
|
97
|
+
"label": "Add the curated stacks section to the home page (Surface 3)",
|
|
98
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
99
|
+
"summary": "Theme customizer > Home page > Add section > runwell-bundle-home-stacks. Position per home_strip_position config (default: 3rd section)."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "add-pdp-pairs-with-section",
|
|
103
|
+
"label": "Add the PDP 'Pairs well with' widget (Surface 2)",
|
|
104
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
105
|
+
"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."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "add-pdp-banner-section",
|
|
109
|
+
"label": "Add the PDP bundle banner (Surface 5)",
|
|
110
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
111
|
+
"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."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"id": "verify-cart-drawer-coordination",
|
|
115
|
+
"label": "Verify cart drawer bundle cross-sell coordinates with cart-cross-sell (Surface 4)",
|
|
116
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
117
|
+
"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."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "rebuild-bundle-index",
|
|
121
|
+
"label": "Rebuild the bundle_index metaobject after creating or editing bundles",
|
|
122
|
+
"url": "https://admin.shopify.com/store/{store_handle}/apps",
|
|
123
|
+
"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."
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Mobile QA checklist: runwell-bundle-system v1
|
|
2
|
+
|
|
3
|
+
Real-device QA at 375px viewport for every enabled bundle surface. Runs on Lushi staging after BS-15 wires the module. Must pass before any production deploy.
|
|
4
|
+
|
|
5
|
+
## Environment
|
|
6
|
+
|
|
7
|
+
- Real iPhone (iPhone 12 or newer; iOS 17+).
|
|
8
|
+
- Safari (not Chrome iOS; Safari is the rendering engine on iOS).
|
|
9
|
+
- Lushi staging dev store URL.
|
|
10
|
+
- 3 sample bundles configured per BS-15.
|
|
11
|
+
|
|
12
|
+
## Per-surface checks
|
|
13
|
+
|
|
14
|
+
### Surface 1: /bundles collection page
|
|
15
|
+
|
|
16
|
+
- [ ] Page loads without console errors.
|
|
17
|
+
- [ ] Single-column grid at 375px.
|
|
18
|
+
- [ ] Hero card variant renders for the first bundle when `feature_first_bundle` is on.
|
|
19
|
+
- [ ] Card spacing 24px on mobile.
|
|
20
|
+
- [ ] Sticky page heading visible while scrolling.
|
|
21
|
+
- [ ] All cards tappable; tap routes to bundle PDP.
|
|
22
|
+
- [ ] Strikethrough subtotal + savings badge legible.
|
|
23
|
+
|
|
24
|
+
### Surface 2: PDP "Pairs well with"
|
|
25
|
+
|
|
26
|
+
- [ ] Renders below buy box on a non-bundle PDP with matching bundles.
|
|
27
|
+
- [ ] Self-suppresses on a non-bundle PDP without matching bundles.
|
|
28
|
+
- [ ] Card width 96% of viewport.
|
|
29
|
+
- [ ] Eyebrow and heading legible.
|
|
30
|
+
- [ ] Tap routes to bundle PDP.
|
|
31
|
+
|
|
32
|
+
### Surface 3: home page curated stacks
|
|
33
|
+
|
|
34
|
+
- [ ] Vertical stack (no horizontal carousel) at 375px.
|
|
35
|
+
- [ ] All 3 to 4 cards visible without horizontal swipe.
|
|
36
|
+
- [ ] Section heading and eyebrow at top.
|
|
37
|
+
- [ ] Background band setting respected (white / oat / celadon-tint).
|
|
38
|
+
- [ ] Each card tappable; tap routes to bundle PDP.
|
|
39
|
+
|
|
40
|
+
### Surface 4: cart drawer bundle xsell
|
|
41
|
+
|
|
42
|
+
- [ ] Drawer opens, bundle xsell card visible at top of xsell area.
|
|
43
|
+
- [ ] cart-cross-sell does NOT also render (coordination contract).
|
|
44
|
+
- [ ] CTA "Add bundle and save" visible and tappable.
|
|
45
|
+
- [ ] Tap CTA: in-cart components removed, bundle product added, drawer refreshes.
|
|
46
|
+
- [ ] Performance: match logic runs in under 100ms.
|
|
47
|
+
|
|
48
|
+
### Surface 5: PDP bundle banner
|
|
49
|
+
|
|
50
|
+
- [ ] Renders near buy box on non-bundle PDPs with matches.
|
|
51
|
+
- [ ] 44px height enforced.
|
|
52
|
+
- [ ] Tap routes to bundle PDP.
|
|
53
|
+
- [ ] Copy template `{savings_pct}` resolved to actual value.
|
|
54
|
+
- [ ] Background brand-tinted (celadon by default), contrast text legible.
|
|
55
|
+
|
|
56
|
+
### Surface 6: collection page bundles filter
|
|
57
|
+
|
|
58
|
+
Skipped for Lushi v1 (no collection pages enabled). Re-enable once Lushi has a /collections/skincare or similar.
|
|
59
|
+
|
|
60
|
+
- [ ] Filter chip + 2 pinned cards render above the collection grid.
|
|
61
|
+
- [ ] Chip tap filters grid to bundles only.
|
|
62
|
+
- [ ] Chip tap again clears the filter.
|
|
63
|
+
- [ ] URL syncs `?filter=bundle`.
|
|
64
|
+
|
|
65
|
+
### Bundle PDP, Mode A (Lusha pattern, when v1.1 enables)
|
|
66
|
+
|
|
67
|
+
- [ ] Radio picker tappable.
|
|
68
|
+
- [ ] Selected tier visually distinct (brand-color border).
|
|
69
|
+
- [ ] Per-tier price + savings badge legible.
|
|
70
|
+
- [ ] FOMO countdown updates (every second when sub-day; every minute when day+).
|
|
71
|
+
- [ ] Sticky ATC at bottom of viewport when scrolled past buy box.
|
|
72
|
+
|
|
73
|
+
### Bundle PDP, Mode B (Lushi pattern)
|
|
74
|
+
|
|
75
|
+
- [ ] Component breakdown list legible.
|
|
76
|
+
- [ ] Component thumbnails 60px square (80px at 1024px+).
|
|
77
|
+
- [ ] Cross-supplier disclosure renders when `bundle_cross_supplier` is on and `bundle_supplier_count > 1`.
|
|
78
|
+
- [ ] Strikethrough subtotal + savings badge correct per pricing model.
|
|
79
|
+
- [ ] ATC adds bundle to cart.
|
|
80
|
+
|
|
81
|
+
## Cross-cutting checks
|
|
82
|
+
|
|
83
|
+
- [ ] No horizontal scroll on any surface.
|
|
84
|
+
- [ ] All tap targets >= 44px.
|
|
85
|
+
- [ ] Brand fonts loaded; no FOUT.
|
|
86
|
+
- [ ] Lighthouse mobile score >= 80 on bundle PDP and /bundles page.
|
|
87
|
+
- [ ] Total module byte footprint under 80KB combined (Liquid + CSS + JS, pre-minification).
|
|
88
|
+
- [ ] No console errors on any surface.
|
|
89
|
+
|
|
90
|
+
## Screenshot capture
|
|
91
|
+
|
|
92
|
+
Per surface, capture 3 states:
|
|
93
|
+
1. Default render.
|
|
94
|
+
2. Interaction state (tap-active, modal open, etc.).
|
|
95
|
+
3. Edge case (no matches, out of stock, cross-supplier disclosure visible).
|
|
96
|
+
|
|
97
|
+
File path: `_clients/capital-v/lushi/qa/bundle-system-mobile-YYYY-MM-DD/surface-N-state.png`.
|
|
98
|
+
|
|
99
|
+
## Sign-off
|
|
100
|
+
|
|
101
|
+
- [ ] All applicable checks above passed.
|
|
102
|
+
- [ ] Screenshots captured and committed.
|
|
103
|
+
- [ ] Any failures logged as follow-up issues and resolved before BS-15 sign-off.
|
|
104
|
+
- [ ] Lighthouse mobile scores documented in the QA handoff doc.
|
|
105
|
+
- [ ] QA owner + date: ___________________
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
runwell-bundle-system: runwell-bundle-cart-xsell.liquid.
|
|
3
|
+
Surface 4: cart drawer bundle cross-sell. Suggests "complete the
|
|
4
|
+
stack" when 1 to N-1 components of a defined bundle sit in cart.
|
|
5
|
+
|
|
6
|
+
How it wires:
|
|
7
|
+
1. This section emits a JSON island with the bundle_index forward and
|
|
8
|
+
reverse maps so the JS does not need a network round trip.
|
|
9
|
+
2. The section also emits an empty <div> slot that JS fills with a
|
|
10
|
+
bundle card (rendered via Section Rendering API call when a match
|
|
11
|
+
is detected).
|
|
12
|
+
3. JS dispatches `runwell:bundle-xsell-active` when it injects content
|
|
13
|
+
so the cart-cross-sell snippet can short-circuit.
|
|
14
|
+
|
|
15
|
+
Coordination contract: see modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid
|
|
16
|
+
for the receiving guard. See _clients/capital-v/lushi/specs/bundle-system/spec.md
|
|
17
|
+
section 6.1.
|
|
18
|
+
{%- endcomment -%}
|
|
19
|
+
|
|
20
|
+
{%- if settings.bundle_system__surface_4_cart_drawer_xsell_enabled == false -%}
|
|
21
|
+
{%- comment -%} Tenant has disabled this surface. {%- endcomment -%}
|
|
22
|
+
{%- else -%}
|
|
23
|
+
{%- assign bundle_index = shop.metaobjects.bundle_index.entries.value -%}
|
|
24
|
+
{%- assign render_slot = false -%}
|
|
25
|
+
{%- if bundle_index and bundle_index.products and bundle_index.bundles -%}
|
|
26
|
+
{%- assign render_slot = true -%}
|
|
27
|
+
{%- endif -%}
|
|
28
|
+
|
|
29
|
+
{%- if render_slot -%}
|
|
30
|
+
<aside
|
|
31
|
+
class="runwell-bundle-cart-xsell"
|
|
32
|
+
data-runwell-bundle-xsell-slot
|
|
33
|
+
data-eyebrow="{{ section.settings.eyebrow | escape }}"
|
|
34
|
+
data-cta="{{ section.settings.cta | escape }}"
|
|
35
|
+
></aside>
|
|
36
|
+
|
|
37
|
+
<script type="application/json" data-runwell-bundle-index>
|
|
38
|
+
{{ bundle_index | json }}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
{{ 'runwell-bundle-system.css' | asset_url | stylesheet_tag }}
|
|
42
|
+
<script src="{{ 'runwell-bundle-system.js' | asset_url }}" defer="defer"></script>
|
|
43
|
+
{%- endif -%}
|
|
44
|
+
{%- endif -%}
|
|
45
|
+
|
|
46
|
+
{% schema %}
|
|
47
|
+
{
|
|
48
|
+
"name": "Bundle cart cross-sell",
|
|
49
|
+
"tag": "section",
|
|
50
|
+
"class": "section-runwell-bundle-cart-xsell",
|
|
51
|
+
"settings": [
|
|
52
|
+
{ "type": "text", "id": "eyebrow", "label": "Eyebrow", "default": "Complete the stack" },
|
|
53
|
+
{ "type": "text", "id": "cta", "label": "CTA label", "default": "Add bundle and save" }
|
|
54
|
+
],
|
|
55
|
+
"presets": [
|
|
56
|
+
{ "name": "Bundle: cart drawer cross-sell", "category": "Runwell" }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
{% endschema %}
|