@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
package/bin/runwell-shopify
CHANGED
|
@@ -8,6 +8,7 @@ import { remove } from '../lib/remove.js';
|
|
|
8
8
|
import { doctor } from '../lib/doctor.js';
|
|
9
9
|
import { validate } from '../lib/validate.js';
|
|
10
10
|
import { qa } from '../lib/qa.js';
|
|
11
|
+
import { qaBundles } from '../lib/qa-bundles.js';
|
|
11
12
|
import { init } from '../lib/init.js';
|
|
12
13
|
import { upgradeBaseline } from '../lib/upgrade-baseline.js';
|
|
13
14
|
import { diffBaseline } from '../lib/diff-baseline.js';
|
|
@@ -55,6 +56,9 @@ Commands:
|
|
|
55
56
|
qa Code-first QA pipeline (validate-config, doctor,
|
|
56
57
|
template-integrity, orphan-assets, theme-check).
|
|
57
58
|
Run before any visual QA or "done" declaration.
|
|
59
|
+
Add --bundles to run the bundle-system check
|
|
60
|
+
(admin metafields, cross-supplier disclosure,
|
|
61
|
+
customizer contract) instead of the full pipeline.
|
|
58
62
|
init <client> Scaffold a new tenant theme directory pinned to
|
|
59
63
|
the dawn-runwell baseline + toolkit. Use --baseline
|
|
60
64
|
to override the default pin.
|
|
@@ -98,7 +102,12 @@ flags.toolkitRoot = TOOLKIT_ROOT;
|
|
|
98
102
|
await validate(flags);
|
|
99
103
|
break;
|
|
100
104
|
case 'qa':
|
|
101
|
-
|
|
105
|
+
if (flags.bundles) {
|
|
106
|
+
flags.toolkitRoot = flags.toolkitRoot || TOOLKIT_ROOT;
|
|
107
|
+
await qaBundles(flags);
|
|
108
|
+
} else {
|
|
109
|
+
await qa(flags);
|
|
110
|
+
}
|
|
102
111
|
break;
|
|
103
112
|
case 'init':
|
|
104
113
|
await init(flags);
|
package/lib/list.js
CHANGED
|
@@ -59,33 +59,37 @@ function listTenants(flags) {
|
|
|
59
59
|
|
|
60
60
|
console.log(`Runwell Shopify tenants (registry: ${registryPath})\n`);
|
|
61
61
|
console.log(`Toolkit current: ${toolkitCurrent}\n`);
|
|
62
|
-
console.log(' Tenant Store
|
|
63
|
-
console.log(' --------- -------------------------------------------
|
|
62
|
+
console.log(' Tenant Store Config pin Synced ver Modules Brand CSS Drift Status');
|
|
63
|
+
console.log(' --------- ------------------------------------------- ------------ ----------- ------- --------- ----- ----------');
|
|
64
64
|
|
|
65
65
|
let drifters = 0;
|
|
66
|
+
let exposed = 0;
|
|
66
67
|
|
|
67
68
|
for (const [slug, t] of tenants.sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
68
69
|
const store = (t.store || '?').padEnd(43);
|
|
69
70
|
const tenantConfig = readTenantConfig(t.theme_repo_local);
|
|
70
|
-
const basePin = (tenantConfig?.baseline || t.baseline || '?').padEnd(33);
|
|
71
71
|
const configPin = (tenantConfig?.toolkit_version || '?').padEnd(12);
|
|
72
72
|
const syncedVer = (readManifestToolkit(t.theme_repo_local) || '?').padEnd(11);
|
|
73
73
|
const moduleCount = (t.modules_enabled || []).length.toString().padEnd(7);
|
|
74
|
+
const brandCss = hasTenantBrandCss(slug, t.theme_repo_local) ? 'present ' : 'MISSING ';
|
|
75
|
+
if (brandCss.trim() === 'MISSING') exposed++;
|
|
74
76
|
const drift = computeDrift(tenantConfig?.toolkit_version, toolkitCurrent);
|
|
75
77
|
if (drift !== 'ok') drifters++;
|
|
76
78
|
const driftStr = drift.padEnd(5);
|
|
77
79
|
const status = (t.status || '?');
|
|
78
|
-
console.log(` ${slug.padEnd(9)} ${store} ${
|
|
80
|
+
console.log(` ${slug.padEnd(9)} ${store} ${configPin} ${syncedVer} ${moduleCount} ${brandCss} ${driftStr} ${status}`);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
console.log(`\nTotal: ${tenants.length} tenants
|
|
83
|
+
console.log(`\nTotal: ${tenants.length} tenants.`);
|
|
84
|
+
console.log(` Drift: ${drifters} of ${tenants.length} pinned below toolkit current (${toolkitCurrent}).`);
|
|
85
|
+
console.log(` Exposed: ${exposed} of ${tenants.length} missing tenant brand CSS (will inherit toolkit token changes raw).`);
|
|
82
86
|
|
|
83
87
|
if (flags.verbose) {
|
|
84
88
|
console.log('\nLegend:');
|
|
85
|
-
console.log('
|
|
86
|
-
console.log('
|
|
87
|
-
console.log('
|
|
88
|
-
console.log(' Drift:
|
|
89
|
+
console.log(' Config pin: from runwell.config.json toolkit_version (truth source). ^X.Y.Z accepts forward minor bumps.');
|
|
90
|
+
console.log(' Synced ver: actual toolkit version recorded by the last `runwell-shopify sync` (from runwell-manifest.json).');
|
|
91
|
+
console.log(' Brand CSS: "present" if assets/<tenant>-brand.css exists, "MISSING" if not. Per docs/CUSTOMIZATION-LEVELS.md, every tenant should ship one to insulate the storefront from upstream token tuning.');
|
|
92
|
+
console.log(' Drift: "ok" if config-pin minor >= toolkit-current minor; "stale" if behind. "stale" with synced=current means the files are current but the pin lies.');
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -135,6 +139,15 @@ function readTenantConfig(themeRepoLocal) {
|
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
function hasTenantBrandCss(slug, themeRepoLocal) {
|
|
143
|
+
if (!themeRepoLocal) return false;
|
|
144
|
+
const candidates = [
|
|
145
|
+
path.join(themeRepoLocal, 'assets', `${slug}-brand.css`),
|
|
146
|
+
path.join(themeRepoLocal, 'assets', 'tenant-brand.css'),
|
|
147
|
+
];
|
|
148
|
+
return candidates.some(p => fs.existsSync(p));
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
function computeDrift(pin, current) {
|
|
139
152
|
if (!pin || !current) return '?';
|
|
140
153
|
const pinMinor = extractMinor(pin);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/* qa-bundles: cross-supplier and bundle metafield consistency check.
|
|
5
|
+
|
|
6
|
+
Invoked via `runwell-shopify qa --bundles`. Two layers:
|
|
7
|
+
|
|
8
|
+
A. Local checks (this file): manifest + contract integrity.
|
|
9
|
+
B. Store checks (deferred): per-bundle Shopify Admin GraphQL reads to
|
|
10
|
+
verify bundle_cross_supplier matches actual Carro supplier count.
|
|
11
|
+
Store checks land in BS-15 (Lushi integration) once the bundle
|
|
12
|
+
module is enabled on staging and a real bundle_index exists.
|
|
13
|
+
|
|
14
|
+
Local stages:
|
|
15
|
+
1. admin-metafields-defined the 19 product metafields are present
|
|
16
|
+
in modules/runwell-bundle-system/admin-metafields.json
|
|
17
|
+
2. disclosure-snippet-exists the cross-supplier disclosure renders
|
|
18
|
+
guard `bundle_cross_supplier && bundle_supplier_count > 1`
|
|
19
|
+
3. settings-contract the cross_supplier_disclosure customizer
|
|
20
|
+
key is wired in settings.json with the
|
|
21
|
+
{n} placeholder
|
|
22
|
+
|
|
23
|
+
The Shopify Admin API stage (store-bundle-audit) is stubbed and reports
|
|
24
|
+
"deferred" until BS-15 wires the live check. */
|
|
25
|
+
|
|
26
|
+
export async function qaBundles(flags) {
|
|
27
|
+
const toolkitRoot = path.resolve(flags.toolkitRoot || process.cwd());
|
|
28
|
+
const moduleDir = path.join(toolkitRoot, 'modules', 'runwell-bundle-system');
|
|
29
|
+
|
|
30
|
+
const findings = { errors: [], warnings: [], info: [] };
|
|
31
|
+
const log = (level, stage, msg) => {
|
|
32
|
+
findings[level].push({ stage, msg });
|
|
33
|
+
const tag = level === 'errors' ? 'FAIL' : level === 'warnings' ? 'WARN' : 'OK ';
|
|
34
|
+
console.log(` [${tag}] ${stage}: ${msg}`);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
console.log(`runwell-shopify qa --bundles @ ${toolkitRoot}`);
|
|
38
|
+
console.log('');
|
|
39
|
+
|
|
40
|
+
// 1. admin-metafields-defined
|
|
41
|
+
console.log('1/4 admin-metafields-defined');
|
|
42
|
+
const adminPath = path.join(moduleDir, 'admin-metafields.json');
|
|
43
|
+
if (!fs.existsSync(adminPath)) {
|
|
44
|
+
log('errors', 'admin-metafields-defined', 'admin-metafields.json missing');
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
const admin = JSON.parse(fs.readFileSync(adminPath, 'utf8'));
|
|
48
|
+
const defs = admin.product_metafields?.definitions || [];
|
|
49
|
+
const required = [
|
|
50
|
+
'bundle_mode', 'bundle_pricing_model', 'bundle_pricing_value',
|
|
51
|
+
'bundle_components', 'bundle_quantity_tiers', 'bundle_cross_supplier',
|
|
52
|
+
'bundle_supplier_count'
|
|
53
|
+
];
|
|
54
|
+
const present = new Set(defs.map(d => d.key));
|
|
55
|
+
const missing = required.filter(k => !present.has(k));
|
|
56
|
+
if (missing.length > 0) {
|
|
57
|
+
log('errors', 'admin-metafields-defined', `missing required keys: ${missing.join(', ')}`);
|
|
58
|
+
} else {
|
|
59
|
+
log('info', 'admin-metafields-defined', `${defs.length} definitions present, all required keys covered`);
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
log('errors', 'admin-metafields-defined', `admin-metafields.json invalid JSON: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
console.log('');
|
|
66
|
+
|
|
67
|
+
// 2. disclosure-snippet-exists
|
|
68
|
+
console.log('2/4 disclosure-snippet-exists');
|
|
69
|
+
const snippetPath = path.join(moduleDir, 'snippets', 'runwell-bundle-cross-supplier.liquid');
|
|
70
|
+
if (!fs.existsSync(snippetPath)) {
|
|
71
|
+
log('errors', 'disclosure-snippet-exists', 'runwell-bundle-cross-supplier.liquid missing');
|
|
72
|
+
} else {
|
|
73
|
+
const snippet = fs.readFileSync(snippetPath, 'utf8');
|
|
74
|
+
if (!snippet.includes('bundle_supplier_count')) {
|
|
75
|
+
log('warnings', 'disclosure-snippet-exists', 'snippet does not reference bundle_supplier_count');
|
|
76
|
+
} else {
|
|
77
|
+
log('info', 'disclosure-snippet-exists', 'snippet present and references supplier_count');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log('');
|
|
81
|
+
|
|
82
|
+
// 3. settings-contract
|
|
83
|
+
console.log('3/4 settings-contract');
|
|
84
|
+
const settingsPath = path.join(moduleDir, 'settings.json');
|
|
85
|
+
if (!fs.existsSync(settingsPath)) {
|
|
86
|
+
log('errors', 'settings-contract', 'settings.json missing');
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
90
|
+
const ids = (settings.settings || []).map(s => s.id);
|
|
91
|
+
if (!ids.includes('bundle_system__cross_supplier_disclosure')) {
|
|
92
|
+
log('errors', 'settings-contract', 'bundle_system__cross_supplier_disclosure missing from settings.json');
|
|
93
|
+
} else {
|
|
94
|
+
const setting = settings.settings.find(s => s.id === 'bundle_system__cross_supplier_disclosure');
|
|
95
|
+
if (!(setting.default || '').includes('{n}')) {
|
|
96
|
+
log('warnings', 'settings-contract', 'cross_supplier_disclosure default lacks {n} placeholder');
|
|
97
|
+
} else {
|
|
98
|
+
log('info', 'settings-contract', 'cross_supplier_disclosure wired with {n} placeholder');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
log('errors', 'settings-contract', `settings.json invalid JSON: ${e.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
|
|
107
|
+
// 4. store-bundle-audit (deferred until BS-15)
|
|
108
|
+
console.log('4/4 store-bundle-audit');
|
|
109
|
+
log('info', 'store-bundle-audit',
|
|
110
|
+
'deferred until BS-15. Will read each bundle product via Shopify Admin ' +
|
|
111
|
+
'GraphQL, count distinct Carro suppliers via product.metafields, and ' +
|
|
112
|
+
'flag mismatches with bundle_cross_supplier / bundle_supplier_count.');
|
|
113
|
+
console.log('');
|
|
114
|
+
|
|
115
|
+
console.log(`summary: ${findings.errors.length} errors, ${findings.warnings.length} warnings, ${findings.info.length} info`);
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
package/lib/qa.js
CHANGED
|
@@ -10,16 +10,19 @@ import { loadConfig, loadModuleManifest, resolveVariant } from './config-loader.
|
|
|
10
10
|
if the theme cannot be inspected at all (no manifest, no config).
|
|
11
11
|
|
|
12
12
|
Stages:
|
|
13
|
-
1. validate-config
|
|
14
|
-
2. doctor
|
|
15
|
-
3. template-integrity
|
|
16
|
-
4. orphan-assets
|
|
17
|
-
5.
|
|
13
|
+
1. validate-config runwell.config.json shape + module references
|
|
14
|
+
2. doctor drift between runwell-manifest and synced files
|
|
15
|
+
3. template-integrity templates/*.json sections referenced in order
|
|
16
|
+
4. orphan-assets runwell-* files with no matching enabled module
|
|
17
|
+
5. brand-fields {{brand.X}} references defined in tenant config
|
|
18
|
+
6. shared-token-impact shared-token VALUE diffs between synced + toolkit-current
|
|
19
|
+
cross-checked against tenant brand CSS coverage
|
|
20
|
+
7. theme-check shopify CLI theme-check (lint), if available
|
|
18
21
|
|
|
19
22
|
Caller can skip stages with --skip <name,name>.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
|
-
const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'brand-fields', 'theme-check'];
|
|
25
|
+
const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'brand-fields', 'shared-token-impact', 'theme-check'];
|
|
23
26
|
|
|
24
27
|
/* Walk the toolkit modules tree and collect every {{brand.X}} reference.
|
|
25
28
|
Returns the set of unique X identifiers (e.g. 'primary', 'rain-forrest',
|
|
@@ -43,6 +46,129 @@ function collectBrandFieldReferences(modulesDir) {
|
|
|
43
46
|
return refs;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/* Parse a CSS file for top-level :root --runwell-X declarations.
|
|
50
|
+
Returns Map<token-name, value-string>. Whitespace-collapsed values so
|
|
51
|
+
identical sizes match regardless of formatting.
|
|
52
|
+
|
|
53
|
+
Brace-balanced block extraction. Naive regex (`/:root\s*\{(.*?)\}/`)
|
|
54
|
+
breaks on `{{brand.X}}` Liquid placeholders inside the root block:
|
|
55
|
+
the `}}` terminates the lazy match prematurely. This walker counts
|
|
56
|
+
braces and stops at the actual matching close. */
|
|
57
|
+
function parseRunwellTokens(filePath) {
|
|
58
|
+
const out = new Map();
|
|
59
|
+
if (!fs.existsSync(filePath)) return out;
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
61
|
+
const rootStart = content.search(/:root\s*\{/);
|
|
62
|
+
if (rootStart < 0) return out;
|
|
63
|
+
const openIdx = content.indexOf('{', rootStart);
|
|
64
|
+
let depth = 0;
|
|
65
|
+
let blockEnd = -1;
|
|
66
|
+
for (let i = openIdx; i < content.length; i++) {
|
|
67
|
+
const c = content[i];
|
|
68
|
+
if (c === '{') depth++;
|
|
69
|
+
else if (c === '}') {
|
|
70
|
+
depth--;
|
|
71
|
+
if (depth === 0) { blockEnd = i; break; }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (blockEnd < 0) return out;
|
|
75
|
+
const block = content.slice(openIdx + 1, blockEnd);
|
|
76
|
+
// Strip Liquid placeholders so they don't break the value matcher
|
|
77
|
+
// (we treat the placeholder as the literal value; comparison logic
|
|
78
|
+
// upstream skips entries whose toolkit-current value starts with '{{').
|
|
79
|
+
const decl = /(--runwell-[a-z0-9-]+)\s*:\s*([^;]+);/gi;
|
|
80
|
+
let m;
|
|
81
|
+
while ((m = decl.exec(block)) !== null) {
|
|
82
|
+
out.set(m[1], m[2].replace(/\s+/g, ' ').trim());
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Read tenant brand CSS files (if any) and collect every --runwell-X token
|
|
88
|
+
the tenant overrides at any cascade scope. Used to determine whether a
|
|
89
|
+
shared-token value change will reach the storefront or be masked. */
|
|
90
|
+
function collectTenantTokenOverrides(targetDir, clientSlug) {
|
|
91
|
+
const overrides = new Set();
|
|
92
|
+
const candidates = [
|
|
93
|
+
path.join(targetDir, 'assets', `${clientSlug}-brand.css`),
|
|
94
|
+
path.join(targetDir, 'assets', 'tenant-brand.css'),
|
|
95
|
+
];
|
|
96
|
+
for (const file of candidates) {
|
|
97
|
+
if (!fs.existsSync(file)) continue;
|
|
98
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
99
|
+
const decl = /(--runwell-[a-z0-9-]+)\s*:/gi;
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = decl.exec(content)) !== null) overrides.add(m[1]);
|
|
102
|
+
}
|
|
103
|
+
return overrides;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runSharedTokenImpactProbe(targetDir, toolkitRoot, log) {
|
|
107
|
+
const sharedFiles = [
|
|
108
|
+
{
|
|
109
|
+
synced: path.join(targetDir, 'assets', 'runwell-tokens.css'),
|
|
110
|
+
current: path.join(toolkitRoot, 'modules', '_shared', 'css-tokens', 'assets', 'runwell-tokens.css'),
|
|
111
|
+
label: 'tokens',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
synced: path.join(targetDir, 'assets', 'runwell-typography.css'),
|
|
115
|
+
current: path.join(toolkitRoot, 'modules', '_shared', 'css-typography', 'assets', 'runwell-typography.css'),
|
|
116
|
+
label: 'typography',
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
let clientSlug = '';
|
|
121
|
+
try {
|
|
122
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(targetDir, 'runwell.config.json'), 'utf8'));
|
|
123
|
+
clientSlug = cfg.client || '';
|
|
124
|
+
} catch { /* no config; probe still runs against synced vs current */ }
|
|
125
|
+
|
|
126
|
+
const tenantOverrides = clientSlug ? collectTenantTokenOverrides(targetDir, clientSlug) : new Set();
|
|
127
|
+
let totalDiffs = 0;
|
|
128
|
+
let exposedDiffs = 0;
|
|
129
|
+
|
|
130
|
+
for (const { synced, current, label } of sharedFiles) {
|
|
131
|
+
if (!fs.existsSync(synced)) {
|
|
132
|
+
log('warnings', 'shared-token-impact', `${label}: ${path.basename(synced)} missing in synced theme; cannot compare`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!fs.existsSync(current)) {
|
|
136
|
+
log('warnings', 'shared-token-impact', `${label}: toolkit-current source not found at ${current}; cannot compare`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const oldTokens = parseRunwellTokens(synced);
|
|
140
|
+
const newTokens = parseRunwellTokens(current);
|
|
141
|
+
const allKeys = new Set([...oldTokens.keys(), ...newTokens.keys()]);
|
|
142
|
+
for (const key of allKeys) {
|
|
143
|
+
const a = oldTokens.get(key);
|
|
144
|
+
const b = newTokens.get(key);
|
|
145
|
+
// Skip Liquid-interpolated tokens: brand colors get resolved from
|
|
146
|
+
// runwell.config.json during sync, so the toolkit-current placeholder
|
|
147
|
+
// is not directly comparable to the synced hex value. Only flag
|
|
148
|
+
// design-system tokens that ship with literal values.
|
|
149
|
+
if (b && b.includes('{{')) continue;
|
|
150
|
+
if (a && a.includes('{{')) continue;
|
|
151
|
+
if (a === b) continue;
|
|
152
|
+
totalDiffs++;
|
|
153
|
+
const insulated = tenantOverrides.has(key);
|
|
154
|
+
const verdict = insulated ? 'insulated' : 'EXPOSED';
|
|
155
|
+
const detail = a == null
|
|
156
|
+
? `new token (will be added: ${b})`
|
|
157
|
+
: b == null
|
|
158
|
+
? `token removed (was: ${a})`
|
|
159
|
+
: `${a} -> ${b}`;
|
|
160
|
+
const level = insulated ? 'info' : 'warnings';
|
|
161
|
+
if (!insulated) exposedDiffs++;
|
|
162
|
+
log(level, 'shared-token-impact', `${key}: ${detail} [${verdict}${insulated ? ' by tenant brand CSS' : '; tenant brand CSS does not override this token'}]`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (totalDiffs === 0) {
|
|
166
|
+
log('info', 'shared-token-impact', 'no shared-token value drift between synced theme and toolkit-current');
|
|
167
|
+
} else {
|
|
168
|
+
log('info', 'shared-token-impact', `${totalDiffs} shared tokens differ between synced and toolkit-current; ${exposedDiffs} would surface visually on next sync (no tenant override).`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
46
172
|
export async function qa(flags) {
|
|
47
173
|
const targetDir = path.resolve(flags.target || process.cwd());
|
|
48
174
|
const skip = new Set((flags.skip || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
@@ -59,7 +185,7 @@ export async function qa(flags) {
|
|
|
59
185
|
|
|
60
186
|
// Stage 1: validate-config
|
|
61
187
|
if (!skip.has('validate-config')) {
|
|
62
|
-
console.log('1/
|
|
188
|
+
console.log('1/7 validate-config');
|
|
63
189
|
try {
|
|
64
190
|
const { config } = loadConfig(flags.config || './runwell.config.json');
|
|
65
191
|
if (!config.client) log('errors', 'validate-config', 'missing required field "client"');
|
|
@@ -82,7 +208,7 @@ export async function qa(flags) {
|
|
|
82
208
|
|
|
83
209
|
// Stage 2: doctor (manifest drift)
|
|
84
210
|
if (!skip.has('doctor')) {
|
|
85
|
-
console.log('2/
|
|
211
|
+
console.log('2/7 doctor (manifest drift)');
|
|
86
212
|
const manifestPath = path.join(targetDir, 'runwell-manifest.json');
|
|
87
213
|
if (!fs.existsSync(manifestPath)) {
|
|
88
214
|
log('errors', 'doctor', 'no runwell-manifest.json (run "runwell-shopify sync" first)');
|
|
@@ -103,7 +229,7 @@ export async function qa(flags) {
|
|
|
103
229
|
|
|
104
230
|
// Stage 3: template-integrity (every section in templates/*.json must exist)
|
|
105
231
|
if (!skip.has('template-integrity')) {
|
|
106
|
-
console.log('3/
|
|
232
|
+
console.log('3/7 template-integrity');
|
|
107
233
|
const tmplDir = path.join(targetDir, 'templates');
|
|
108
234
|
if (fs.existsSync(tmplDir)) {
|
|
109
235
|
const jsons = fs.readdirSync(tmplDir).filter(f => f.endsWith('.json'));
|
|
@@ -146,7 +272,7 @@ export async function qa(flags) {
|
|
|
146
272
|
|
|
147
273
|
// Stage 4: orphan-assets (runwell-* files in theme that aren't synced from any enabled module)
|
|
148
274
|
if (!skip.has('orphan-assets')) {
|
|
149
|
-
console.log('4/
|
|
275
|
+
console.log('4/7 orphan-assets');
|
|
150
276
|
const manifestPath = path.join(targetDir, 'runwell-manifest.json');
|
|
151
277
|
if (fs.existsSync(manifestPath)) {
|
|
152
278
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
@@ -172,7 +298,7 @@ export async function qa(flags) {
|
|
|
172
298
|
|
|
173
299
|
// Stage 5: brand-fields (every {{brand.X}} reference in toolkit modules must be defined in tenant config.brand)
|
|
174
300
|
if (!skip.has('brand-fields')) {
|
|
175
|
-
console.log('5/
|
|
301
|
+
console.log('5/7 brand-fields');
|
|
176
302
|
try {
|
|
177
303
|
const { config } = loadConfig(flags.config || './runwell.config.json');
|
|
178
304
|
const brand = config.brand || {};
|
|
@@ -199,9 +325,17 @@ export async function qa(flags) {
|
|
|
199
325
|
console.log('');
|
|
200
326
|
}
|
|
201
327
|
|
|
202
|
-
// Stage 6:
|
|
328
|
+
// Stage 6: shared-token-impact (compare synced shared tokens vs toolkit-current;
|
|
329
|
+
// surface any value diffs and whether the tenant brand CSS overrides them)
|
|
330
|
+
if (!skip.has('shared-token-impact')) {
|
|
331
|
+
console.log('6/7 shared-token-impact');
|
|
332
|
+
runSharedTokenImpactProbe(targetDir, flags.toolkitRoot, log);
|
|
333
|
+
console.log('');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Stage 7: theme-check via Shopify CLI (parse --output json for accuracy)
|
|
203
337
|
if (!skip.has('theme-check')) {
|
|
204
|
-
console.log('
|
|
338
|
+
console.log('7/7 theme-check');
|
|
205
339
|
await new Promise(resolve => {
|
|
206
340
|
const proc = spawn('shopify', ['theme', 'check', '--path', targetDir, '--output', 'json'], {
|
|
207
341
|
stdio: ['ignore', 'pipe', 'pipe']
|
package/modules/INDEX.md
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
Auto-generated by `scripts/generate-index.mjs`. Do not edit by hand.
|
|
4
4
|
Skills (shopify-storefront, shopify-cro, shopify-cli-ops, shopify-admin-browser) grep this file before writing custom Liquid.
|
|
5
5
|
|
|
6
|
-
Total modules:
|
|
6
|
+
Total modules: 39.
|
|
7
7
|
|
|
8
8
|
## By category
|
|
9
9
|
|
|
10
10
|
- **branding**: care-coaching-medvi, product-trio-medvi
|
|
11
|
-
- **catalog**: bundle-builder, quantity-breaks, subscriptions
|
|
11
|
+
- **catalog**: bundle-builder, quantity-breaks, runwell-bundle-system, subscriptions
|
|
12
12
|
- **conversion**: cart-cross-sell, cart-freeship-progress, cart-usps, exit-intent, gift-with-purchase, post-purchase-upsell, quick-view, risk-reversal, shipping-bar, sticky-atc
|
|
13
13
|
- **customer**: loyalty-tiers, wishlist
|
|
14
14
|
- **pdp**: comparison-table, delivery-estimate, inventory-urgency, pdp-ingredients, pdp-journal-link, pdp-trust-checks, recently-viewed
|
|
@@ -19,7 +19,7 @@ Total modules: 38.
|
|
|
19
19
|
|
|
20
20
|
| Name | Category | Replaces | Files | Config keys | Variants | Admin steps |
|
|
21
21
|
|---|---|---|---|---|---|---|
|
|
22
|
-
| `bundle-builder` | catalog |
|
|
22
|
+
| `bundle-builder` | catalog | (native build) | sections:1 assets:1 | heading, sale_prefix, show_rating, rating_score, rating_count, show_trust_badges, trust_1, trust_2, trust_3, fomo_mode, fomo_cycle_days, fomo_stock_count | (none) | (none) |
|
|
23
23
|
| `care-coaching-medvi` | branding | (native build) | sections:1 snippets:1 assets:1 | section_eyebrow, headline_pre, headline_post, headline_accent_color, block_heading, circular_text, circular_text_position, footer_link_label, layout, bg_band | (none) | (none) |
|
|
24
24
|
| `cart-cross-sell` | conversion | Rebuy / OneClickUpsell pre-purchase upsell | snippets:1 | eyebrow, cta_label | (none) | (none) |
|
|
25
25
|
| `cart-freeship-progress` | conversion | Bold Free Shipping Manager and similar app features | snippets:1 | threshold_cents, away_text, unlocked_message | (none) | (none) |
|
|
@@ -47,6 +47,7 @@ Total modules: 38.
|
|
|
47
47
|
| `recently-viewed` | pdp | (native build) | sections:1 assets:2 | eyebrow, heading, background_color | (none) | (none) |
|
|
48
48
|
| `reviews` | social-proof | (native build) | sections:1 assets:1 | heading | (none) | (none) |
|
|
49
49
|
| `risk-reversal` | conversion | (native build) | sections:1 | icon, heading, body, link_label, link_url, background_color, text_color | (none) | (none) |
|
|
50
|
+
| `runwell-bundle-system` | catalog | the legacy bundle-builder module | sections:6 snippets:8 assets:2 | surface_1_collection_page_enabled, surface_2_pdp_pairs_with_enabled, surface_3_home_stacks_enabled, surface_4_cart_drawer_xsell_enabled, surface_5_pdp_banner_enabled, surface_6_collection_filter_enabled, surface_2_eyebrow, surface_2_heading, surface_3_eyebrow, surface_3_heading, surface_4_eyebrow, surface_4_cta, surface_5_copy_template, cross_supplier_disclosure, home_strip_position | (none) | install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index |
|
|
50
51
|
| `scrolling-ticker` | social-proof | announcement-bar / scrolling-text apps | sections:1 assets:1 | (none) | (none) | (none) |
|
|
51
52
|
| `shipping-bar` | conversion | (native build) | sections:1 | threshold_cents, message_below, message_qualified, message_default, background_color, text_color | (none) | (none) |
|
|
52
53
|
| `social-proof-banner` | social-proof | fixed-text social-proof apps | sections:1 assets:1 | (none) | (none) | (none) |
|
|
@@ -63,8 +64,7 @@ Total modules: 38.
|
|
|
63
64
|
### bundle-builder
|
|
64
65
|
|
|
65
66
|
- Category: catalog
|
|
66
|
-
-
|
|
67
|
-
- What: Build-your-own-bundle PDP section.
|
|
67
|
+
- What: DEPRECATED 2026-05-10 in favor of runwell-bundle-system (Mode A).
|
|
68
68
|
- Files: sections:1 assets:1
|
|
69
69
|
- Config: heading, sale_prefix, show_rating, rating_score, rating_count, show_trust_badges, trust_1, trust_2, trust_3, fomo_mode, fomo_cycle_days, fomo_stock_count
|
|
70
70
|
|
|
@@ -264,6 +264,15 @@ Total modules: 38.
|
|
|
264
264
|
- Files: sections:1
|
|
265
265
|
- Config: icon, heading, body, link_label, link_url, background_color, text_color
|
|
266
266
|
|
|
267
|
+
### runwell-bundle-system
|
|
268
|
+
|
|
269
|
+
- Category: catalog
|
|
270
|
+
- Replaces: the legacy bundle-builder module
|
|
271
|
+
- What: Configurable bundles engine.
|
|
272
|
+
- Files: sections:6 snippets:8 assets:2
|
|
273
|
+
- Config: surface_1_collection_page_enabled, surface_2_pdp_pairs_with_enabled, surface_3_home_stacks_enabled, surface_4_cart_drawer_xsell_enabled, surface_5_pdp_banner_enabled, surface_6_collection_filter_enabled, surface_2_eyebrow, surface_2_heading, surface_3_eyebrow, surface_3_heading, surface_4_eyebrow, surface_4_cta, surface_5_copy_template, cross_supplier_disclosure, home_strip_position
|
|
274
|
+
- Admin steps: install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index
|
|
275
|
+
|
|
267
276
|
### scrolling-ticker
|
|
268
277
|
|
|
269
278
|
- Category: social-proof
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
# bundle-builder
|
|
1
|
+
# bundle-builder (deprecated)
|
|
2
|
+
|
|
3
|
+
> **Deprecated since toolkit 0.21.0.** Use `runwell-bundle-system` (Mode A: quantity tiers) instead.
|
|
4
|
+
> This module continues to function for tenants pinned to it; removal target version is 0.22.0.
|
|
5
|
+
>
|
|
6
|
+
> **Migration path:** set `modules.bundle-builder.enabled` to false in `runwell.config.json`, enable `runwell-bundle-system`, and set `bundle_mode: "quantity_tiers"` on the bundle product via the `runwell.bundle_mode` metafield. See `_clients/capital-v/lushi/specs/bundle-system/phase-plan.md` v1.1 for the full Lusha cutover plan.
|
|
2
7
|
|
|
3
8
|
PDP section for build-your-own-bundle merchandising. Slideshow gallery + thumbnail nav + radio bundle picker (1x / 2x / 3x with savings, free-ship and free-gift badges) + sticky ATC + FOMO countdown + scarcity. Migrated from Lusha's bundle-selector.
|
|
4
9
|
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
"name": "bundle-builder",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"category": "catalog",
|
|
5
|
-
"
|
|
5
|
+
"deprecated": true,
|
|
6
|
+
"deprecated_in_favor_of": "runwell-bundle-system",
|
|
7
|
+
"deprecated_since": "0.21.0",
|
|
8
|
+
"removal_target_version": "0.22.0",
|
|
9
|
+
"description": "DEPRECATED 2026-05-10 in favor of runwell-bundle-system (Mode A). Build-your-own-bundle PDP section. Slideshow gallery + thumbnail nav + radio bundle picker (1x / 2x / 3x with savings, free-ship and free-gift badges) + sticky ATC + FOMO countdown + scarcity. Lusha migration to runwell-bundle-system Mode A happens in v1.1; module removed at toolkit 0.22.0.",
|
|
6
10
|
"files": {
|
|
7
11
|
"sections": ["sections/runwell-bundle-builder.liquid"],
|
|
8
12
|
"assets": ["assets/runwell-bundle-builder.css"]
|
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
already in cart. Replaces Rebuy/OneClickUpsell pre-purchase upsell
|
|
4
4
|
display. Render inside cart-drawer.liquid via:
|
|
5
5
|
{% render 'runwell-cart-xsell' %}
|
|
6
|
+
|
|
7
|
+
BS-6 coordination: runwell-bundle-system Surface 4 (cart drawer
|
|
8
|
+
bundle cross-sell) takes priority on partial-bundle matches. When
|
|
9
|
+
that surface dispatches the `runwell:bundle-xsell-active` event,
|
|
10
|
+
this fallback removes itself so customers do not see two competing
|
|
11
|
+
upsell cards.
|
|
6
12
|
{%- endcomment -%}
|
|
7
13
|
|
|
14
|
+
<div data-runwell-cart-xsell-fallback>
|
|
8
15
|
{%- if cart.item_count > 0 -%}
|
|
9
16
|
{%- assign cart_handles = cart.items | map: 'handle' -%}
|
|
10
17
|
{%- assign suggestion = blank -%}
|
|
@@ -38,3 +45,12 @@
|
|
|
38
45
|
</div>
|
|
39
46
|
{%- endif -%}
|
|
40
47
|
{%- endif -%}
|
|
48
|
+
</div>
|
|
49
|
+
<script>
|
|
50
|
+
(function () {
|
|
51
|
+
document.addEventListener('runwell:bundle-xsell-active', function () {
|
|
52
|
+
var fallback = document.querySelector('[data-runwell-cart-xsell-fallback]');
|
|
53
|
+
if (fallback) fallback.remove();
|
|
54
|
+
});
|
|
55
|
+
})();
|
|
56
|
+
</script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# runwell-bundle-system
|
|
2
|
+
|
|
3
|
+
Configurable bundles engine for Runwell Shopify tenants.
|
|
4
|
+
|
|
5
|
+
## Modes
|
|
6
|
+
- Mode A: single-product quantity tiers (Lusha pattern).
|
|
7
|
+
- Mode B: curated multi-product bundles (Lushi pattern).
|
|
8
|
+
- Mode C: mix-and-match / build-your-own (v1.5).
|
|
9
|
+
- Mode D: subscription bundles (v2).
|
|
10
|
+
|
|
11
|
+
## Display surfaces
|
|
12
|
+
1. Dedicated /bundles collection page.
|
|
13
|
+
2. PDP "Pairs well with" widget.
|
|
14
|
+
3. Home page curated stacks.
|
|
15
|
+
4. Cart drawer bundle cross-sell.
|
|
16
|
+
5. PDP bundle banner.
|
|
17
|
+
6. Collection page bundles filter.
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
Sits on top of Shopify's free first-party Bundles app. Reads per-bundle config from product metafields (namespace: runwell). See spec.md for the data model.
|
|
21
|
+
|
|
22
|
+
## Replaces
|
|
23
|
+
- bundle-builder (deprecated; Lusha migrates in v1.1).
|
|
24
|
+
|
|
25
|
+
## Lineage
|
|
26
|
+
Net-new Runwell module. Spec: `_clients/capital-v/lushi/specs/bundle-system/spec.md`.
|
|
27
|
+
|
|
28
|
+
## Files in this module
|
|
29
|
+
|
|
30
|
+
- `module.json`: module manifest, config schema, admin_steps array.
|
|
31
|
+
- `admin-metafields.json`: source of truth for the 19 product metafields and the `bundle_index` shop metaobject. Source for `runwell-shopify provision-metafields` and merchant manual setup.
|
|
32
|
+
- `settings.json`: theme customizer settings patch. Appended to tenant `config/settings_schema.json` by `runwell-shopify sync`.
|
|
33
|
+
- `sections/`: one Liquid section per display surface (placeholders until BS-2 onwards).
|
|
34
|
+
- `snippets/`: shared bundle UI partials (placeholders until BS-2 onwards).
|
|
35
|
+
- `assets/`: module CSS and JS (placeholders until BS-2 onwards).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Source of truth for runwell.bundle_* product metafield definitions. Mirrors spec.md section 2.1. Read by runwell-shopify provision-metafields (when implemented) and by admin-guide.md for manual setup.",
|
|
3
|
+
"spec_ref": "_clients/capital-v/lushi/specs/bundle-system/spec.md#21-per-bundle-product-metafields",
|
|
4
|
+
"product_metafields": {
|
|
5
|
+
"namespace": "runwell",
|
|
6
|
+
"owner_type": "PRODUCT",
|
|
7
|
+
"definitions": [
|
|
8
|
+
{ "key": "bundle_mode", "name": "Bundle mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"quantity_tiers\",\"multi_product\",\"mix_match\",\"subscription\"]" }] },
|
|
9
|
+
{ "key": "bundle_pricing_model", "name": "Bundle pricing model", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"tier_quantity\",\"fixed_price\",\"percent_off_subtotal\",\"dollar_off_subtotal\"]" }] },
|
|
10
|
+
{ "key": "bundle_pricing_value", "name": "Bundle pricing value (JSON)", "type": "json", "description": "Shape varies by pricing_model. See spec.md section 2.2." },
|
|
11
|
+
{ "key": "bundle_components", "name": "Bundle components (JSON)", "type": "json", "description": "Required for multi_product / mix_match. Array of {product_handle, qty}." },
|
|
12
|
+
{ "key": "bundle_quantity_tiers", "name": "Bundle quantity tiers (JSON)", "type": "json", "description": "Required for quantity_tiers. Array of {qty, discount_pct}. Mirrors quantity_breaks schema." },
|
|
13
|
+
{ "key": "bundle_show_in_catalog", "name": "Show in main catalog", "type": "boolean", "default": true },
|
|
14
|
+
{ "key": "bundle_surfaces_enabled", "name": "Surfaces enabled (JSON)", "type": "json", "description": "Per-bundle surface allowlist. Array of 1..6. Absent = all enabled at tenant level." },
|
|
15
|
+
{ "key": "bundle_copy", "name": "Per-surface copy (JSON)", "type": "json", "description": "Per-surface eyebrow/heading/cta overrides." },
|
|
16
|
+
{ "key": "bundle_free_gift_enabled", "name": "Free gift enabled", "type": "boolean", "default": false },
|
|
17
|
+
{ "key": "bundle_free_gift_handle", "name": "Free gift product handle", "type": "single_line_text_field" },
|
|
18
|
+
{ "key": "bundle_fomo_mode", "name": "FOMO mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"none\",\"discount\",\"scarcity\",\"both\"]" }] },
|
|
19
|
+
{ "key": "bundle_fomo_cycle_days", "name": "FOMO cycle (days)", "type": "number_integer" },
|
|
20
|
+
{ "key": "bundle_fomo_stock_count", "name": "FOMO stock count", "type": "number_integer" },
|
|
21
|
+
{ "key": "bundle_cross_supplier", "name": "Cross-supplier", "type": "boolean", "default": false },
|
|
22
|
+
{ "key": "bundle_supplier_count", "name": "Supplier count", "type": "number_integer" },
|
|
23
|
+
{ "key": "bundle_savings_pct", "name": "Computed savings percent", "type": "number_decimal", "description": "Precomputed for fast banner / card render. Recomputed by runwell-shopify rebuild-bundle-index." },
|
|
24
|
+
{ "key": "subscription_enabled", "name": "Subscription enabled (v2)", "type": "boolean", "default": false, "v2_only": true },
|
|
25
|
+
{ "key": "subscription_interval", "name": "Subscription interval (JSON, v2)", "type": "json", "v2_only": true },
|
|
26
|
+
{ "key": "subscription_discount_pct", "name": "Subscription discount percent (v2)", "type": "number_decimal", "v2_only": true },
|
|
27
|
+
{ "key": "subscription_badge_copy", "name": "Subscription badge copy (v2)", "type": "single_line_text_field", "v2_only": true }
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"shop_metaobjects": [
|
|
31
|
+
{
|
|
32
|
+
"type": "bundle_index",
|
|
33
|
+
"name": "Bundle index",
|
|
34
|
+
"description": "Forward map (bundles -> components) and reverse map (products -> bundles) used by surfaces 2/4 to avoid N+1 reads. Single instance per shop. Rebuilt by runwell-shopify rebuild-bundle-index or by Shopify Flow on bundle product create/update.",
|
|
35
|
+
"field_definitions": [
|
|
36
|
+
{ "key": "entries", "name": "Entries", "type": "json", "description": "{products: {handle: [bundle_handles]}, bundles: {handle: {components: {handle: qty}}}}" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"cross_field_validation": [
|
|
41
|
+
{ "rule": "bundle_quantity_tiers required when bundle_mode == 'quantity_tiers'", "enforced_by": "runwell-shopify qa --bundles" },
|
|
42
|
+
{ "rule": "bundle_components required when bundle_mode in ['multi_product','mix_match']", "enforced_by": "runwell-shopify qa --bundles" },
|
|
43
|
+
{ "rule": "bundle_pricing_value shape matches bundle_pricing_model (see spec.md section 2.2)", "enforced_by": "runwell-shopify qa --bundles" },
|
|
44
|
+
{ "rule": "bundle_free_gift_handle required when bundle_free_gift_enabled == true", "enforced_by": "runwell-shopify qa --bundles" }
|
|
45
|
+
]
|
|
46
|
+
}
|