@runwell/shopify-toolkit 0.4.0 → 0.5.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 +7 -0
- package/lib/qa.js +210 -0
- package/lib/template.js +2 -1
- package/modules/INDEX.md +4 -3
- package/modules/sticky-atc/assets/runwell-sticky-atc.css +117 -0
- package/modules/sticky-atc/module.json +6 -3
- package/modules/sticky-atc/sections/runwell-pdp-sticky.liquid +1 -0
- package/package.json +1 -1
package/bin/runwell-shopify
CHANGED
|
@@ -7,6 +7,7 @@ import { add } from '../lib/add.js';
|
|
|
7
7
|
import { remove } from '../lib/remove.js';
|
|
8
8
|
import { doctor } from '../lib/doctor.js';
|
|
9
9
|
import { validate } from '../lib/validate.js';
|
|
10
|
+
import { qa } from '../lib/qa.js';
|
|
10
11
|
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
13
|
const __dirname = path.dirname(__filename);
|
|
@@ -45,6 +46,9 @@ Commands:
|
|
|
45
46
|
remove <module> Disable + clean a module's files
|
|
46
47
|
doctor Detect drift between manifest and theme
|
|
47
48
|
validate Run @shopify/dev-mcp validators on synced files
|
|
49
|
+
qa Code-first QA pipeline (validate-config, doctor,
|
|
50
|
+
template-integrity, orphan-assets, theme-check).
|
|
51
|
+
Run before any visual QA or "done" declaration.
|
|
48
52
|
help Show this help
|
|
49
53
|
|
|
50
54
|
Common options:
|
|
@@ -79,6 +83,9 @@ flags.toolkitRoot = TOOLKIT_ROOT;
|
|
|
79
83
|
case 'validate':
|
|
80
84
|
await validate(flags);
|
|
81
85
|
break;
|
|
86
|
+
case 'qa':
|
|
87
|
+
await qa(flags);
|
|
88
|
+
break;
|
|
82
89
|
case undefined:
|
|
83
90
|
case 'help':
|
|
84
91
|
case '--help':
|
package/lib/qa.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { loadConfig, loadModuleManifest, resolveVariant } from './config-loader.js';
|
|
5
|
+
|
|
6
|
+
/* qa: code-first QA pipeline for a Runwell-managed Shopify theme.
|
|
7
|
+
|
|
8
|
+
Runs in fixed order; each stage is non-fatal so callers see the full
|
|
9
|
+
picture before fixing. Exits 0 with a structured summary; exits 1 only
|
|
10
|
+
if the theme cannot be inspected at all (no manifest, no config).
|
|
11
|
+
|
|
12
|
+
Stages:
|
|
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. theme-check shopify CLI theme-check (lint), if available
|
|
18
|
+
|
|
19
|
+
Caller can skip stages with --skip <name,name>.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'theme-check'];
|
|
23
|
+
|
|
24
|
+
export async function qa(flags) {
|
|
25
|
+
const targetDir = path.resolve(flags.target || process.cwd());
|
|
26
|
+
const skip = new Set((flags.skip || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
27
|
+
|
|
28
|
+
const findings = { errors: [], warnings: [], info: [] };
|
|
29
|
+
const log = (level, stage, msg) => {
|
|
30
|
+
findings[level].push({ stage, msg });
|
|
31
|
+
const tag = level === 'errors' ? 'FAIL' : level === 'warnings' ? 'WARN' : 'OK ';
|
|
32
|
+
console.log(` [${tag}] ${stage}: ${msg}`);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
console.log(`runwell-shopify qa @ ${targetDir}`);
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
// Stage 1: validate-config
|
|
39
|
+
if (!skip.has('validate-config')) {
|
|
40
|
+
console.log('1/5 validate-config');
|
|
41
|
+
try {
|
|
42
|
+
const { config } = loadConfig(flags.config || './runwell.config.json');
|
|
43
|
+
if (!config.client) log('errors', 'validate-config', 'missing required field "client"');
|
|
44
|
+
if (!config.store) log('warnings', 'validate-config', 'missing field "store" (registry lookups will fail)');
|
|
45
|
+
const enabled = Object.entries(config.modules || {}).filter(([, v]) => v.enabled !== false);
|
|
46
|
+
log('info', 'validate-config', `${enabled.length} modules enabled`);
|
|
47
|
+
for (const [name, entry] of enabled) {
|
|
48
|
+
try {
|
|
49
|
+
const m = loadModuleManifest(flags.toolkitRoot, name);
|
|
50
|
+
if (entry.variant) resolveVariant(m, entry.variant);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
log('errors', 'validate-config', `${name}: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
log('errors', 'validate-config', e.message);
|
|
57
|
+
}
|
|
58
|
+
console.log('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Stage 2: doctor (manifest drift)
|
|
62
|
+
if (!skip.has('doctor')) {
|
|
63
|
+
console.log('2/5 doctor (manifest drift)');
|
|
64
|
+
const manifestPath = path.join(targetDir, 'runwell-manifest.json');
|
|
65
|
+
if (!fs.existsSync(manifestPath)) {
|
|
66
|
+
log('errors', 'doctor', 'no runwell-manifest.json (run "runwell-shopify sync" first)');
|
|
67
|
+
} else {
|
|
68
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
69
|
+
let missing = 0;
|
|
70
|
+
for (const tracked of manifest.files) {
|
|
71
|
+
if (!fs.existsSync(path.join(targetDir, tracked.path))) {
|
|
72
|
+
log('errors', 'doctor', `tracked file missing: ${tracked.path}`);
|
|
73
|
+
missing++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!missing) log('info', 'doctor', `${manifest.files.length} tracked files all present`);
|
|
77
|
+
log('info', 'doctor', `manifest toolkit version: ${manifest.toolkit_version}`);
|
|
78
|
+
}
|
|
79
|
+
console.log('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stage 3: template-integrity (every section in templates/*.json must exist)
|
|
83
|
+
if (!skip.has('template-integrity')) {
|
|
84
|
+
console.log('3/5 template-integrity');
|
|
85
|
+
const tmplDir = path.join(targetDir, 'templates');
|
|
86
|
+
if (fs.existsSync(tmplDir)) {
|
|
87
|
+
const jsons = fs.readdirSync(tmplDir).filter(f => f.endsWith('.json'));
|
|
88
|
+
const sectionsDir = path.join(targetDir, 'sections');
|
|
89
|
+
const knownSections = new Set();
|
|
90
|
+
if (fs.existsSync(sectionsDir)) {
|
|
91
|
+
for (const f of fs.readdirSync(sectionsDir)) {
|
|
92
|
+
if (f.endsWith('.liquid')) knownSections.add(f.replace(/\.liquid$/, ''));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let badRefs = 0;
|
|
96
|
+
for (const jf of jsons) {
|
|
97
|
+
const tpath = path.join(tmplDir, jf);
|
|
98
|
+
let parsed;
|
|
99
|
+
try { parsed = JSON.parse(fs.readFileSync(tpath, 'utf8')); }
|
|
100
|
+
catch (e) { log('errors', 'template-integrity', `${jf}: invalid JSON: ${e.message}`); continue; }
|
|
101
|
+
const sections = parsed.sections || {};
|
|
102
|
+
const order = parsed.order || Object.keys(sections);
|
|
103
|
+
for (const id of order) {
|
|
104
|
+
if (!sections[id]) {
|
|
105
|
+
log('errors', 'template-integrity', `${jf}: order references unknown section id "${id}"`);
|
|
106
|
+
badRefs++;
|
|
107
|
+
} else {
|
|
108
|
+
const t = sections[id].type;
|
|
109
|
+
// Type can be a built-in (Dawn-shipped, no local file) or a section file we have
|
|
110
|
+
// Only flag if it looks like our convention (no group sections) and isn't found locally and is not a Dawn default name
|
|
111
|
+
if (t && t.startsWith('runwell-') && !knownSections.has(t)) {
|
|
112
|
+
log('errors', 'template-integrity', `${jf}: section "${id}" type "${t}" has no matching sections/${t}.liquid`);
|
|
113
|
+
badRefs++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!badRefs) log('info', 'template-integrity', `${jsons.length} template files all reference valid sections`);
|
|
119
|
+
} else {
|
|
120
|
+
log('warnings', 'template-integrity', 'no templates/ directory');
|
|
121
|
+
}
|
|
122
|
+
console.log('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Stage 4: orphan-assets (runwell-* files in theme that aren't synced from any enabled module)
|
|
126
|
+
if (!skip.has('orphan-assets')) {
|
|
127
|
+
console.log('4/5 orphan-assets');
|
|
128
|
+
const manifestPath = path.join(targetDir, 'runwell-manifest.json');
|
|
129
|
+
if (fs.existsSync(manifestPath)) {
|
|
130
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
131
|
+
const tracked = new Set(manifest.files.map(f => f.path));
|
|
132
|
+
const buckets = ['sections', 'snippets', 'assets', 'templates'];
|
|
133
|
+
let orphans = 0;
|
|
134
|
+
for (const b of buckets) {
|
|
135
|
+
const dir = path.join(targetDir, b);
|
|
136
|
+
if (!fs.existsSync(dir)) continue;
|
|
137
|
+
for (const f of fs.readdirSync(dir)) {
|
|
138
|
+
if (!f.startsWith('runwell-')) continue;
|
|
139
|
+
const rel = `${b}/${f}`;
|
|
140
|
+
if (!tracked.has(rel)) {
|
|
141
|
+
log('warnings', 'orphan-assets', `${rel} is runwell-prefixed but not in current manifest (stale sync?)`);
|
|
142
|
+
orphans++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!orphans) log('info', 'orphan-assets', 'no orphans');
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Stage 5: theme-check via Shopify CLI (parse --output json for accuracy)
|
|
152
|
+
if (!skip.has('theme-check')) {
|
|
153
|
+
console.log('5/5 theme-check');
|
|
154
|
+
await new Promise(resolve => {
|
|
155
|
+
const proc = spawn('shopify', ['theme', 'check', '--path', targetDir, '--output', 'json'], {
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
157
|
+
});
|
|
158
|
+
let stdout = '';
|
|
159
|
+
proc.stdout.on('data', d => stdout += d.toString());
|
|
160
|
+
proc.on('error', () => {
|
|
161
|
+
log('warnings', 'theme-check', 'shopify CLI not available; skipping');
|
|
162
|
+
resolve();
|
|
163
|
+
});
|
|
164
|
+
proc.on('exit', () => {
|
|
165
|
+
let parsed;
|
|
166
|
+
try { parsed = JSON.parse(stdout); } catch { parsed = null; }
|
|
167
|
+
if (!Array.isArray(parsed)) {
|
|
168
|
+
log('warnings', 'theme-check', 'could not parse output');
|
|
169
|
+
return resolve();
|
|
170
|
+
}
|
|
171
|
+
const offenses = parsed.flatMap(f => (f.offenses || []).map(o => ({ ...o, path: f.path })));
|
|
172
|
+
const errs = offenses.filter(o => o.severity === 'error' || o.severity === 0);
|
|
173
|
+
const warns = offenses.filter(o => o.severity === 'warning' || o.severity === 1);
|
|
174
|
+
if (errs.length === 0 && warns.length === 0) {
|
|
175
|
+
log('info', 'theme-check', 'clean');
|
|
176
|
+
} else {
|
|
177
|
+
// Per-check rollup so the summary is actionable
|
|
178
|
+
const byCheck = {};
|
|
179
|
+
for (const o of errs) byCheck[o.check] = (byCheck[o.check] || 0) + 1;
|
|
180
|
+
const top = Object.entries(byCheck).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
181
|
+
.map(([c, n]) => `${c}×${n}`).join(', ');
|
|
182
|
+
log(errs.length ? 'errors' : 'warnings', 'theme-check',
|
|
183
|
+
`${errs.length} errors, ${warns.length} warnings${top ? ' (' + top + ')' : ''}`);
|
|
184
|
+
// List the file-level offenders so users can jump straight to fixes
|
|
185
|
+
const filesWithErr = [...new Set(errs.map(e => path.relative(targetDir, e.path)))];
|
|
186
|
+
for (const f of filesWithErr.slice(0, 8)) {
|
|
187
|
+
const n = errs.filter(e => path.relative(targetDir, e.path) === f).length;
|
|
188
|
+
log('errors', 'theme-check', ` ${f}: ${n} error(s)`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
console.log('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Summary
|
|
198
|
+
console.log('---');
|
|
199
|
+
console.log(`Summary: ${findings.errors.length} errors, ${findings.warnings.length} warnings, ${findings.info.length} info`);
|
|
200
|
+
if (findings.errors.length) {
|
|
201
|
+
console.log('\nErrors (fix before declaring done):');
|
|
202
|
+
findings.errors.forEach(f => console.log(` - [${f.stage}] ${f.msg}`));
|
|
203
|
+
}
|
|
204
|
+
if (findings.warnings.length) {
|
|
205
|
+
console.log('\nWarnings (review):');
|
|
206
|
+
findings.warnings.forEach(f => console.log(` - [${f.stage}] ${f.msg}`));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (findings.errors.length) process.exitCode = 2;
|
|
210
|
+
}
|
package/lib/template.js
CHANGED
|
@@ -21,7 +21,8 @@ export function interpolate(source, vars) {
|
|
|
21
21
|
let working = source;
|
|
22
22
|
|
|
23
23
|
// Second pass: substitute {{config.X}} and {{brand.Y}} and {{client.Z}}
|
|
24
|
-
|
|
24
|
+
// Keys can include hyphens (e.g. brand.rain-forrest) and dots for nesting.
|
|
25
|
+
working = working.replace(/\{\{\s*([a-zA-Z][a-zA-Z0-9_.\-]*)\s*\}\}/g, (match, key) => {
|
|
25
26
|
// If the key starts with a Liquid keyword, leave it alone (it is Shopify Liquid)
|
|
26
27
|
const liquidKeywords = new Set([
|
|
27
28
|
'product', 'collection', 'cart', 'customer', 'shop', 'request',
|
package/modules/INDEX.md
CHANGED
|
@@ -44,7 +44,7 @@ Total modules: 31.
|
|
|
44
44
|
| `reviews` | social-proof | (native build) | sections:1 | heading | (none) | (none) |
|
|
45
45
|
| `risk-reversal` | conversion | (native build) | sections:1 | icon, heading, body, link_label, link_url, background_color, text_color | (none) | (none) |
|
|
46
46
|
| `shipping-bar` | conversion | (native build) | sections:1 | threshold_cents, message_below, message_qualified, message_default, background_color, text_color | (none) | (none) |
|
|
47
|
-
| `sticky-atc` | conversion |
|
|
47
|
+
| `sticky-atc` | conversion | Sticky Add To Cart Booster Pro and similar | sections:1 assets:1 | (none) | (none) | (none) |
|
|
48
48
|
| `subscriptions` | catalog | Recharge / Bold Subscriptions / Appstle for the storefront display layer; subscription management still uses Shopify's native customer account | snippets:1 | one_time_label, subscribe_label, fineprint | (none) | install-shopify-subscriptions + create-selling-plan-group + enable-customer-account-tab |
|
|
49
49
|
| `testimonials` | social-proof | (native build) | sections:1 | eyebrow, heading, background_color, text_color | (none) | (none) |
|
|
50
50
|
| `trust-badges` | social-proof | (native build) | sections:1 | background_color, text_color | (none) | (none) |
|
|
@@ -243,8 +243,9 @@ Total modules: 31.
|
|
|
243
243
|
### sticky-atc
|
|
244
244
|
|
|
245
245
|
- Category: conversion
|
|
246
|
-
-
|
|
247
|
-
-
|
|
246
|
+
- Replaces: Sticky Add To Cart Booster Pro and similar
|
|
247
|
+
- What: Sticky add-to-cart bar that slides up from the bottom on PDP after the buy area scrolls out of view.
|
|
248
|
+
- Files: sections:1 assets:1
|
|
248
249
|
|
|
249
250
|
### subscriptions
|
|
250
251
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* Runwell sticky add-to-cart bar.
|
|
2
|
+
Slides up from bottom on scroll past the buy area; hides when the
|
|
3
|
+
main buy button returns to view (driven by IntersectionObserver in
|
|
4
|
+
the section template).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
.runwell-sticky-atc {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: auto 0 0 0;
|
|
10
|
+
z-index: 60;
|
|
11
|
+
background: #FFFFFF;
|
|
12
|
+
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
|
13
|
+
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
|
14
|
+
transform: translateY(100%);
|
|
15
|
+
transition: transform 0.25s ease, opacity 0.25s ease;
|
|
16
|
+
opacity: 0;
|
|
17
|
+
visibility: hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.runwell-sticky-atc[aria-hidden='false'] {
|
|
21
|
+
transform: translateY(0);
|
|
22
|
+
opacity: 1;
|
|
23
|
+
visibility: visible;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.runwell-sticky-atc__inner {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: space-between;
|
|
30
|
+
gap: 1rem;
|
|
31
|
+
max-width: 1200px;
|
|
32
|
+
margin: 0 auto;
|
|
33
|
+
padding: 0.75rem 1.25rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.runwell-sticky-atc__product {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 0.75rem;
|
|
40
|
+
min-width: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.runwell-sticky-atc__thumb {
|
|
44
|
+
width: 40px;
|
|
45
|
+
height: 40px;
|
|
46
|
+
border-radius: 4px;
|
|
47
|
+
object-fit: cover;
|
|
48
|
+
flex-shrink: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.runwell-sticky-atc__meta {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.runwell-sticky-atc__title {
|
|
58
|
+
font-family: var(--font-heading-family, serif);
|
|
59
|
+
font-style: italic;
|
|
60
|
+
font-size: 0.95rem;
|
|
61
|
+
font-weight: 400;
|
|
62
|
+
line-height: 1.2;
|
|
63
|
+
color: var(--runwell-primary, #1A1A1A);
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
text-overflow: ellipsis;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.runwell-sticky-atc__price {
|
|
70
|
+
font-family: var(--font-body-family, sans-serif);
|
|
71
|
+
font-size: 0.85rem;
|
|
72
|
+
font-weight: 500;
|
|
73
|
+
color: var(--runwell-primary, #1A1A1A);
|
|
74
|
+
opacity: 0.8;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.runwell-sticky-atc__form {
|
|
78
|
+
flex-shrink: 0;
|
|
79
|
+
margin: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.runwell-sticky-atc__cta {
|
|
83
|
+
background: var(--runwell-primary, #1A1A1A);
|
|
84
|
+
color: #FFFFFF;
|
|
85
|
+
border: none;
|
|
86
|
+
border-radius: 0;
|
|
87
|
+
padding: 0.75rem 1.5rem;
|
|
88
|
+
font-family: var(--font-body-family, sans-serif);
|
|
89
|
+
font-size: 0.9rem;
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
letter-spacing: 0.04em;
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
transition: opacity 0.15s ease;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.runwell-sticky-atc__cta:hover {
|
|
98
|
+
opacity: 0.85;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.runwell-sticky-atc__cta:disabled {
|
|
102
|
+
opacity: 0.5;
|
|
103
|
+
cursor: not-allowed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@media (max-width: 749px) {
|
|
107
|
+
.runwell-sticky-atc__inner {
|
|
108
|
+
padding: 0.6rem 0.9rem;
|
|
109
|
+
}
|
|
110
|
+
.runwell-sticky-atc__title {
|
|
111
|
+
font-size: 0.85rem;
|
|
112
|
+
}
|
|
113
|
+
.runwell-sticky-atc__cta {
|
|
114
|
+
padding: 0.6rem 1.1rem;
|
|
115
|
+
font-size: 0.8rem;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sticky-atc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"category": "conversion",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Sticky add-to-cart bar that slides up from the bottom on PDP after the buy area scrolls out of view. Native, no app. Replaces Sticky Add To Cart Booster Pro and similar.",
|
|
6
6
|
"files": {
|
|
7
7
|
"sections": [
|
|
8
8
|
"sections/runwell-pdp-sticky.liquid"
|
|
9
|
+
],
|
|
10
|
+
"assets": [
|
|
11
|
+
"assets/runwell-sticky-atc.css"
|
|
9
12
|
]
|
|
10
13
|
},
|
|
11
14
|
"config": {
|
|
12
15
|
"schema": {}
|
|
13
16
|
}
|
|
14
|
-
}
|
|
17
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
{%- endcomment -%}
|
|
6
6
|
|
|
7
7
|
{%- if template == 'product' and product != blank -%}
|
|
8
|
+
{{ 'runwell-sticky-atc.css' | asset_url | stylesheet_tag }}
|
|
8
9
|
<div class="runwell-sticky-atc" data-runwell-sticky aria-hidden="true">
|
|
9
10
|
<div class="runwell-sticky-atc__inner">
|
|
10
11
|
<div class="runwell-sticky-atc__product">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwell/shopify-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Reusable Shopify theme modules from Runwell. Replaces typically app-driven features (reviews, wishlist, urgency, FAQ, post-purchase upsell, exit popups, free-ship progress, sticky ATC, testimonials, badges, bundles) with native Liquid + JS + CSS that ship across multiple client themes via a config-driven sync CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|