@runwell/shopify-toolkit 0.23.0 → 0.24.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/modules/runwell-bundle-system/admin-metafields.json +4 -1
- package/modules/runwell-bundle-system/assets/runwell-bundle-quantity-builder.css +383 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +202 -0
- package/modules/runwell-bundle-system/module.json +6 -3
- package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +2 -1
- package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +20 -8
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +2 -1
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +2 -1
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +3 -1
- package/modules/runwell-bundle-system/sections/runwell-bundle-quantity-builder.liquid +318 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +15 -2
- package/modules/runwell-bundle-system/snippets/runwell-bundle-cart-xsell.liquid +85 -0
- package/modules/scratch-popup/README.md +88 -0
- package/modules/scratch-popup/SPEC.md +120 -0
- package/modules/scratch-popup/assets/runwell-scratch-popup.css +315 -0
- package/modules/scratch-popup/assets/runwell-scratch-popup.js +367 -0
- package/modules/scratch-popup/module.json +128 -0
- package/modules/scratch-popup/sections/runwell-scratch-popup.liquid +184 -0
- package/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/* Runwell scratch popup. Mystery-discount email capture with foil scratch-to-reveal.
|
|
2
|
+
|
|
3
|
+
Flow (matches the live-vendor pattern: Knowband, Poptin, AppSetup, CI Scratch,
|
|
4
|
+
Triggerbee, Winify, Superpop):
|
|
5
|
+
1. SCRATCH - foil card visible immediately; visitor scratches the foil.
|
|
6
|
+
At ~60% erased, the discount AMOUNT (e.g. "10% off") is
|
|
7
|
+
revealed. The actual code is NOT shown here.
|
|
8
|
+
2. EMAIL - the card stays visible as a trophy. An email form appears
|
|
9
|
+
below: "Where should we send your code?". Submit posts the
|
|
10
|
+
email to Shopify's /contact endpoint in the background.
|
|
11
|
+
3. REVEALED - the discount code is shown with Copy + Shop now (the CTA
|
|
12
|
+
auto-applies the code via Shopify's /discount/CODE URL).
|
|
13
|
+
|
|
14
|
+
Suppression: 30 days via localStorage.runwell_scratch_seen.
|
|
15
|
+
|
|
16
|
+
Discount codes are pre-created on the store (via shopify-discount-create.sh).
|
|
17
|
+
No backend required: a tier is picked client-side (weighted random); the
|
|
18
|
+
matching code is bound to the email via Shopify's one-use-per-customer
|
|
19
|
+
discount setting at checkout. */
|
|
20
|
+
(function () {
|
|
21
|
+
if (typeof window === 'undefined') return;
|
|
22
|
+
|
|
23
|
+
var KEY = 'runwell_scratch_seen';
|
|
24
|
+
var DAYS = 30;
|
|
25
|
+
|
|
26
|
+
var root = document.querySelector('[data-runwell-scratch]');
|
|
27
|
+
if (!root) return;
|
|
28
|
+
|
|
29
|
+
// ---------- Config from data attributes ----------
|
|
30
|
+
var tiers = [
|
|
31
|
+
{
|
|
32
|
+
code: root.dataset.tier1Code,
|
|
33
|
+
pct: parseInt(root.dataset.tier1Pct, 10),
|
|
34
|
+
prob: parseFloat(root.dataset.tier1Prob)
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
code: root.dataset.tier2Code,
|
|
38
|
+
pct: parseInt(root.dataset.tier2Pct, 10),
|
|
39
|
+
prob: parseFloat(root.dataset.tier2Prob)
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
code: root.dataset.tier3Code,
|
|
43
|
+
pct: parseInt(root.dataset.tier3Pct, 10),
|
|
44
|
+
prob: parseFloat(root.dataset.tier3Prob)
|
|
45
|
+
}
|
|
46
|
+
].filter(function (t) { return t.code && t.pct > 0 && t.prob > 0; });
|
|
47
|
+
|
|
48
|
+
var triggerDelaySec = parseInt(root.dataset.triggerDelaySec, 10);
|
|
49
|
+
if (isNaN(triggerDelaySec)) triggerDelaySec = 8;
|
|
50
|
+
var exitIntentEnabled = root.dataset.exitIntent === 'true';
|
|
51
|
+
var thresholdPct = parseInt(root.dataset.scratchThresholdPct, 10);
|
|
52
|
+
if (isNaN(thresholdPct)) thresholdPct = 60;
|
|
53
|
+
|
|
54
|
+
// ---------- Session-suppression ----------
|
|
55
|
+
function seenRecently() {
|
|
56
|
+
try {
|
|
57
|
+
var v = localStorage.getItem(KEY);
|
|
58
|
+
if (!v) return false;
|
|
59
|
+
var ts = parseInt(v, 10);
|
|
60
|
+
if (isNaN(ts)) return false;
|
|
61
|
+
return (Date.now() - ts) < (DAYS * 24 * 60 * 60 * 1000);
|
|
62
|
+
} catch (e) { return false; }
|
|
63
|
+
}
|
|
64
|
+
function markSeen() {
|
|
65
|
+
try { localStorage.setItem(KEY, String(Date.now())); } catch (e) { /* ignore */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (seenRecently()) return;
|
|
69
|
+
|
|
70
|
+
// ---------- Pick tier UP FRONT so the scratch reveal can show the amount ----------
|
|
71
|
+
function pickTier() {
|
|
72
|
+
var totalWeight = tiers.reduce(function (s, t) { return s + t.prob; }, 0);
|
|
73
|
+
if (totalWeight <= 0) return tiers[0];
|
|
74
|
+
var r = Math.random() * totalWeight;
|
|
75
|
+
var acc = 0;
|
|
76
|
+
for (var i = 0; i < tiers.length; i++) {
|
|
77
|
+
acc += tiers[i].prob;
|
|
78
|
+
if (r <= acc) return tiers[i];
|
|
79
|
+
}
|
|
80
|
+
return tiers[tiers.length - 1];
|
|
81
|
+
}
|
|
82
|
+
var selectedTier = tiers.length > 0 ? pickTier() : null;
|
|
83
|
+
|
|
84
|
+
// Stamp the discount amount into all "pct" placeholders so it's ready at scratch reveal.
|
|
85
|
+
if (selectedTier) {
|
|
86
|
+
var amountText = selectedTier.pct + '% off';
|
|
87
|
+
root.querySelectorAll('[data-runwell-scratch-pct], [data-runwell-scratch-pct-trophy], [data-runwell-scratch-pct-final]')
|
|
88
|
+
.forEach(function (el) { el.textContent = amountText; });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------- Modal open / close ----------
|
|
92
|
+
function open() {
|
|
93
|
+
if (seenRecently()) return;
|
|
94
|
+
root.setAttribute('aria-hidden', 'false');
|
|
95
|
+
root.classList.add('is-open');
|
|
96
|
+
document.body.classList.add('runwell-scratch-open');
|
|
97
|
+
// Init canvas on open so the foil is ready to scratch.
|
|
98
|
+
initCanvas();
|
|
99
|
+
}
|
|
100
|
+
function close() {
|
|
101
|
+
root.setAttribute('aria-hidden', 'true');
|
|
102
|
+
root.classList.remove('is-open');
|
|
103
|
+
document.body.classList.remove('runwell-scratch-open');
|
|
104
|
+
markSeen();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- Step navigation ----------
|
|
108
|
+
function showStep(name) {
|
|
109
|
+
root.querySelectorAll('[data-runwell-scratch-step]').forEach(function (el) {
|
|
110
|
+
el.hidden = (el.getAttribute('data-runwell-scratch-step') !== name);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------- Trigger ----------
|
|
115
|
+
var triggered = false;
|
|
116
|
+
function maybeOpen() {
|
|
117
|
+
if (triggered) return;
|
|
118
|
+
triggered = true;
|
|
119
|
+
open();
|
|
120
|
+
}
|
|
121
|
+
if (triggerDelaySec > 0) {
|
|
122
|
+
setTimeout(maybeOpen, triggerDelaySec * 1000);
|
|
123
|
+
} else {
|
|
124
|
+
maybeOpen();
|
|
125
|
+
}
|
|
126
|
+
if (exitIntentEnabled) {
|
|
127
|
+
document.addEventListener('mouseout', function (e) {
|
|
128
|
+
if (e.relatedTarget === null && e.clientY <= 0) maybeOpen();
|
|
129
|
+
});
|
|
130
|
+
if (window.matchMedia('(max-width: 749px)').matches) {
|
|
131
|
+
setTimeout(maybeOpen, 30000);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- Close handlers ----------
|
|
136
|
+
root.querySelectorAll('[data-runwell-scratch-close]').forEach(function (el) {
|
|
137
|
+
el.addEventListener('click', close);
|
|
138
|
+
});
|
|
139
|
+
document.addEventListener('keydown', function (e) {
|
|
140
|
+
if (e.key === 'Escape' && root.classList.contains('is-open')) close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------- Step 1: canvas scratch (visible immediately on open) ----------
|
|
144
|
+
var canvas = root.querySelector('[data-runwell-scratch-canvas]');
|
|
145
|
+
var ctx = null;
|
|
146
|
+
var canvasReady = false;
|
|
147
|
+
var isDrawing = false;
|
|
148
|
+
var threshold = thresholdPct / 100;
|
|
149
|
+
var checkTimer = null;
|
|
150
|
+
var scratchRevealed = false;
|
|
151
|
+
|
|
152
|
+
function initCanvas() {
|
|
153
|
+
if (canvasReady) return;
|
|
154
|
+
if (!canvas) { advanceToEmail(); return; }
|
|
155
|
+
|
|
156
|
+
// Set canvas pixel size to match its CSS size, accounting for DPR.
|
|
157
|
+
var rect = canvas.getBoundingClientRect();
|
|
158
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
159
|
+
// Modal may not have painted yet; retry once.
|
|
160
|
+
setTimeout(initCanvas, 100);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
var dpr = window.devicePixelRatio || 1;
|
|
164
|
+
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
|
165
|
+
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
|
166
|
+
|
|
167
|
+
ctx = canvas.getContext('2d');
|
|
168
|
+
if (!ctx) { advanceToEmail(); return; }
|
|
169
|
+
ctx.scale(dpr, dpr);
|
|
170
|
+
|
|
171
|
+
// Foil color from CSS variable.
|
|
172
|
+
var foilColor = getComputedStyle(root).getPropertyValue('--runwell-scratch-foil').trim() || '#C8B89A';
|
|
173
|
+
ctx.fillStyle = foilColor;
|
|
174
|
+
ctx.fillRect(0, 0, rect.width, rect.height);
|
|
175
|
+
|
|
176
|
+
// Foil texture: subtle diagonal lines for a tactile look.
|
|
177
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.07)';
|
|
178
|
+
ctx.lineWidth = 1;
|
|
179
|
+
for (var x = -rect.height; x < rect.width; x += 8) {
|
|
180
|
+
ctx.beginPath();
|
|
181
|
+
ctx.moveTo(x, 0);
|
|
182
|
+
ctx.lineTo(x + rect.height, rect.height);
|
|
183
|
+
ctx.stroke();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pointer + touch event handlers.
|
|
187
|
+
canvas.addEventListener('pointerdown', startDraw);
|
|
188
|
+
canvas.addEventListener('pointermove', draw);
|
|
189
|
+
canvas.addEventListener('pointerup', endDraw);
|
|
190
|
+
canvas.addEventListener('pointerleave', endDraw);
|
|
191
|
+
canvas.addEventListener('pointercancel', endDraw);
|
|
192
|
+
canvasReady = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getPointerPos(e) {
|
|
196
|
+
var rect = canvas.getBoundingClientRect();
|
|
197
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function startDraw(e) {
|
|
201
|
+
if (scratchRevealed) return;
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
|
|
204
|
+
isDrawing = true;
|
|
205
|
+
var p = getPointerPos(e);
|
|
206
|
+
scratchAt(p.x, p.y, true);
|
|
207
|
+
}
|
|
208
|
+
function draw(e) {
|
|
209
|
+
if (!isDrawing || scratchRevealed) return;
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
var p = getPointerPos(e);
|
|
212
|
+
scratchAt(p.x, p.y, false);
|
|
213
|
+
}
|
|
214
|
+
function endDraw(e) {
|
|
215
|
+
if (!isDrawing) return;
|
|
216
|
+
isDrawing = false;
|
|
217
|
+
try { canvas.releasePointerCapture(e.pointerId); } catch (err) {}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function scratchAt(x, y, isStart) {
|
|
221
|
+
if (!ctx) return;
|
|
222
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
223
|
+
ctx.beginPath();
|
|
224
|
+
ctx.arc(x, y, 26, 0, Math.PI * 2);
|
|
225
|
+
ctx.fill();
|
|
226
|
+
if (!isStart) scheduleCheck();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function scheduleCheck() {
|
|
230
|
+
if (checkTimer) return;
|
|
231
|
+
checkTimer = setTimeout(function () {
|
|
232
|
+
checkTimer = null;
|
|
233
|
+
if (revealedFractionAboveThreshold()) revealAmount();
|
|
234
|
+
}, 250);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function revealedFractionAboveThreshold() {
|
|
238
|
+
if (!ctx || !canvas.width || !canvas.height) return false;
|
|
239
|
+
var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
240
|
+
var data = img.data;
|
|
241
|
+
var sampleW = 40, sampleH = 30;
|
|
242
|
+
var step = Math.max(1, Math.floor(data.length / 4 / (sampleW * sampleH)));
|
|
243
|
+
var clear = 0, total = 0;
|
|
244
|
+
for (var i = 3; i < data.length; i += step * 4) {
|
|
245
|
+
total++;
|
|
246
|
+
if (data[i] === 0) clear++;
|
|
247
|
+
}
|
|
248
|
+
if (!total) return false;
|
|
249
|
+
return (clear / total) >= threshold;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- Scratch reveal -> animate to email step ----------
|
|
253
|
+
function revealAmount() {
|
|
254
|
+
if (scratchRevealed) return;
|
|
255
|
+
scratchRevealed = true;
|
|
256
|
+
var card = root.querySelector('.runwell-scratch__step--scratch .runwell-scratch__card');
|
|
257
|
+
if (card) card.classList.add('is-revealed');
|
|
258
|
+
setTimeout(advanceToEmail, 700);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function advanceToEmail() {
|
|
262
|
+
showStep('email');
|
|
263
|
+
// Focus the email field after step swap.
|
|
264
|
+
setTimeout(function () {
|
|
265
|
+
var inp = root.querySelector('[data-runwell-scratch-email]');
|
|
266
|
+
if (inp) inp.focus();
|
|
267
|
+
}, 200);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Accessibility fallback: tap-to-reveal button bypasses scratch.
|
|
271
|
+
var a11yBtn = root.querySelector('[data-runwell-scratch-a11y-reveal]');
|
|
272
|
+
if (a11yBtn) a11yBtn.addEventListener('click', revealAmount);
|
|
273
|
+
|
|
274
|
+
// ---------- Step 2: email submit -> step 3 ----------
|
|
275
|
+
var emailInput = root.querySelector('[data-runwell-scratch-email]');
|
|
276
|
+
var emailSubmitBtn = root.querySelector('[data-runwell-scratch-email-submit]');
|
|
277
|
+
var tagsInput = root.querySelector('[data-runwell-scratch-tags]');
|
|
278
|
+
|
|
279
|
+
function submitEmail() {
|
|
280
|
+
var email = (emailInput.value || '').trim();
|
|
281
|
+
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
282
|
+
emailInput.focus();
|
|
283
|
+
emailInput.classList.add('is-invalid');
|
|
284
|
+
setTimeout(function () { emailInput.classList.remove('is-invalid'); }, 1200);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Background subscribe to Shopify Customers with the scratch-popup tag.
|
|
289
|
+
try {
|
|
290
|
+
var formData = new FormData();
|
|
291
|
+
formData.append('form_type', 'customer');
|
|
292
|
+
formData.append('utf8', '✓');
|
|
293
|
+
formData.append('contact[tags]', (tagsInput && tagsInput.getAttribute('value')) || 'newsletter, scratch-popup');
|
|
294
|
+
formData.append('contact[email]', email);
|
|
295
|
+
var contactUrl = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root ? window.Shopify.routes.root : '/') + 'contact';
|
|
296
|
+
fetch(contactUrl, {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
body: formData,
|
|
299
|
+
headers: { 'Accept': 'application/json' },
|
|
300
|
+
credentials: 'same-origin'
|
|
301
|
+
}).catch(function () { /* non-fatal */ });
|
|
302
|
+
} catch (e) { /* ignore */ }
|
|
303
|
+
|
|
304
|
+
showCode();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (emailSubmitBtn) {
|
|
308
|
+
emailSubmitBtn.addEventListener('click', function (e) {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
submitEmail();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (emailInput) {
|
|
314
|
+
emailInput.addEventListener('keydown', function (e) {
|
|
315
|
+
if (e.key === 'Enter') {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
submitEmail();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------- Step 3: code revealed ----------
|
|
323
|
+
function showCode() {
|
|
324
|
+
if (!selectedTier) return;
|
|
325
|
+
var disp = root.querySelector('[data-runwell-scratch-code-display]');
|
|
326
|
+
if (disp) disp.textContent = selectedTier.code;
|
|
327
|
+
|
|
328
|
+
// Update Shop now CTA to auto-apply the discount via Shopify's
|
|
329
|
+
// /discount/CODE redirect, then carry the visitor to the configured URL.
|
|
330
|
+
var shopBtn = root.querySelector('[data-runwell-scratch-shop]');
|
|
331
|
+
if (shopBtn) {
|
|
332
|
+
var dest = shopBtn.getAttribute('href') || '/collections/all';
|
|
333
|
+
shopBtn.setAttribute('href', '/discount/' + selectedTier.code + '?redirect=' + encodeURIComponent(dest));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
showStep('revealed');
|
|
337
|
+
markSeen();
|
|
338
|
+
|
|
339
|
+
// Auto-copy to clipboard.
|
|
340
|
+
try {
|
|
341
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
342
|
+
navigator.clipboard.writeText(selectedTier.code).catch(function () {});
|
|
343
|
+
}
|
|
344
|
+
} catch (e) { /* ignore */ }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Copy button.
|
|
348
|
+
var copyBtn = root.querySelector('[data-runwell-scratch-copy]');
|
|
349
|
+
if (copyBtn) {
|
|
350
|
+
copyBtn.addEventListener('click', function () {
|
|
351
|
+
if (!selectedTier) return;
|
|
352
|
+
var text = selectedTier.code;
|
|
353
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
354
|
+
navigator.clipboard.writeText(text).catch(function () {});
|
|
355
|
+
} else {
|
|
356
|
+
var ta = document.createElement('textarea');
|
|
357
|
+
ta.value = text;
|
|
358
|
+
document.body.appendChild(ta);
|
|
359
|
+
ta.select();
|
|
360
|
+
try { document.execCommand('copy'); } catch (e) {}
|
|
361
|
+
document.body.removeChild(ta);
|
|
362
|
+
}
|
|
363
|
+
copyBtn.classList.add('is-copied');
|
|
364
|
+
copyBtn.textContent = 'Copied';
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scratch-popup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"status": "ready",
|
|
5
|
+
"category": "conversion",
|
|
6
|
+
"description": "Mystery-discount scratch popup. Email capture + foil-card scratch-to-reveal that unlocks a unique discount code per visitor. KH-validated alternative to spin-the-wheel.",
|
|
7
|
+
"files": {
|
|
8
|
+
"sections": [
|
|
9
|
+
"sections/runwell-scratch-popup.liquid"
|
|
10
|
+
],
|
|
11
|
+
"assets": [
|
|
12
|
+
"assets/runwell-scratch-popup.js",
|
|
13
|
+
"assets/runwell-scratch-popup.css"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"depends_on": {
|
|
17
|
+
"scopes": [
|
|
18
|
+
"write_discounts",
|
|
19
|
+
"read_discounts",
|
|
20
|
+
"write_customers",
|
|
21
|
+
"read_customers"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"config": {
|
|
25
|
+
"schema": {
|
|
26
|
+
"enabled": {
|
|
27
|
+
"type": "boolean",
|
|
28
|
+
"default": false,
|
|
29
|
+
"label": "Enable scratch popup"
|
|
30
|
+
},
|
|
31
|
+
"triggers.first_visit_delay_sec": {
|
|
32
|
+
"type": "number",
|
|
33
|
+
"default": 8,
|
|
34
|
+
"label": "First-visit delay (seconds)"
|
|
35
|
+
},
|
|
36
|
+
"triggers.exit_intent": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"default": true,
|
|
39
|
+
"label": "Trigger on exit intent"
|
|
40
|
+
},
|
|
41
|
+
"triggers.manual_cta_selector": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"default": null,
|
|
44
|
+
"label": "Manual CTA selector (optional)"
|
|
45
|
+
},
|
|
46
|
+
"email_gate": {
|
|
47
|
+
"type": "boolean",
|
|
48
|
+
"default": true,
|
|
49
|
+
"label": "Require email before scratch"
|
|
50
|
+
},
|
|
51
|
+
"scratch_threshold_pct": {
|
|
52
|
+
"type": "number",
|
|
53
|
+
"default": 60,
|
|
54
|
+
"label": "Foil-erase percentage before auto-reveal"
|
|
55
|
+
},
|
|
56
|
+
"tiers": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"default": [
|
|
59
|
+
{ "code_prefix": "MYSTERY10", "percentage": 10, "probability": 0.60 },
|
|
60
|
+
{ "code_prefix": "MYSTERY15", "percentage": 15, "probability": 0.30 },
|
|
61
|
+
{ "code_prefix": "MYSTERY20", "percentage": 20, "probability": 0.10 }
|
|
62
|
+
],
|
|
63
|
+
"label": "Discount tiers + probability"
|
|
64
|
+
},
|
|
65
|
+
"expiry_days": {
|
|
66
|
+
"type": "number",
|
|
67
|
+
"default": 30,
|
|
68
|
+
"label": "Discount-code expiry"
|
|
69
|
+
},
|
|
70
|
+
"copy.heading": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"default": "Scratch to reveal your discount",
|
|
73
|
+
"label": "Modal heading"
|
|
74
|
+
},
|
|
75
|
+
"copy.subheading": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"default": "One scratch, one code, just for you.",
|
|
78
|
+
"label": "Modal subheading"
|
|
79
|
+
},
|
|
80
|
+
"copy.email_placeholder": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"default": "you@email.com",
|
|
83
|
+
"label": "Email field placeholder"
|
|
84
|
+
},
|
|
85
|
+
"copy.email_cta": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"default": "Continue",
|
|
88
|
+
"label": "Email CTA"
|
|
89
|
+
},
|
|
90
|
+
"copy.reveal_cta": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"default": "Shop now",
|
|
93
|
+
"label": "Post-reveal CTA"
|
|
94
|
+
},
|
|
95
|
+
"copy.post_reveal_subheading": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"default": "Code copied. Also sent to your inbox.",
|
|
98
|
+
"label": "Post-reveal subheading"
|
|
99
|
+
},
|
|
100
|
+
"design.foil_color": {
|
|
101
|
+
"type": "color",
|
|
102
|
+
"default": "#C8B89A",
|
|
103
|
+
"label": "Foil color"
|
|
104
|
+
},
|
|
105
|
+
"design.foil_texture_url": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"default": null,
|
|
108
|
+
"label": "Optional foil texture image URL"
|
|
109
|
+
},
|
|
110
|
+
"design.accent_color": {
|
|
111
|
+
"type": "color",
|
|
112
|
+
"default": "#5B7A3E",
|
|
113
|
+
"label": "Accent color"
|
|
114
|
+
},
|
|
115
|
+
"design.card_radius_px": {
|
|
116
|
+
"type": "number",
|
|
117
|
+
"default": 16,
|
|
118
|
+
"label": "Card corner radius (px)"
|
|
119
|
+
},
|
|
120
|
+
"klaviyo_list_id": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"default": null,
|
|
123
|
+
"label": "Klaviyo list ID (optional)"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"spec": "SPEC.md"
|
|
128
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
Runwell scratch popup. Mystery-discount email capture with foil
|
|
3
|
+
scratch-to-reveal mechanic. KH-validated alternative to spin-the-wheel.
|
|
4
|
+
|
|
5
|
+
Discount codes are pre-created on the store via
|
|
6
|
+
`infrastructure/scripts/shopify/shopify-discount-create.sh`. Tier codes here
|
|
7
|
+
must match the codes created on the store side. Default codes:
|
|
8
|
+
MYSTERY10 (10% off, once-per-customer)
|
|
9
|
+
MYSTERY15 (15% off, once-per-customer)
|
|
10
|
+
MYSTERY20 (20% off, once-per-customer)
|
|
11
|
+
{%- endcomment -%}
|
|
12
|
+
{{ 'runwell-scratch-popup.css' | asset_url | stylesheet_tag }}
|
|
13
|
+
|
|
14
|
+
<div
|
|
15
|
+
class="runwell-scratch"
|
|
16
|
+
data-runwell-scratch
|
|
17
|
+
data-tier1-code="{{ section.settings.tier1_code }}"
|
|
18
|
+
data-tier1-pct="{{ section.settings.tier1_pct }}"
|
|
19
|
+
data-tier1-prob="{{ section.settings.tier1_prob }}"
|
|
20
|
+
data-tier2-code="{{ section.settings.tier2_code }}"
|
|
21
|
+
data-tier2-pct="{{ section.settings.tier2_pct }}"
|
|
22
|
+
data-tier2-prob="{{ section.settings.tier2_prob }}"
|
|
23
|
+
data-tier3-code="{{ section.settings.tier3_code }}"
|
|
24
|
+
data-tier3-pct="{{ section.settings.tier3_pct }}"
|
|
25
|
+
data-tier3-prob="{{ section.settings.tier3_prob }}"
|
|
26
|
+
data-trigger-delay-sec="{{ section.settings.trigger_delay_sec }}"
|
|
27
|
+
data-exit-intent="{{ section.settings.exit_intent }}"
|
|
28
|
+
data-scratch-threshold-pct="{{ section.settings.scratch_threshold_pct }}"
|
|
29
|
+
style="--runwell-scratch-foil: {{ section.settings.foil_color }}; --runwell-scratch-accent: {{ section.settings.accent_color }};"
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
role="dialog"
|
|
32
|
+
aria-labelledby="runwell-scratch-title"
|
|
33
|
+
>
|
|
34
|
+
<div class="runwell-scratch__backdrop" data-runwell-scratch-close></div>
|
|
35
|
+
|
|
36
|
+
<div class="runwell-scratch__panel">
|
|
37
|
+
<button class="runwell-scratch__close" type="button" data-runwell-scratch-close aria-label="Close">×</button>
|
|
38
|
+
|
|
39
|
+
{%- if section.settings.eyebrow != blank -%}
|
|
40
|
+
<p class="runwell-scratch__eyebrow">{{ section.settings.eyebrow }}</p>
|
|
41
|
+
{%- endif -%}
|
|
42
|
+
|
|
43
|
+
<h2 id="runwell-scratch-title" class="runwell-scratch__heading">{{ section.settings.heading }}</h2>
|
|
44
|
+
|
|
45
|
+
{%- if section.settings.subheading != blank -%}
|
|
46
|
+
<p class="runwell-scratch__subheading">{{ section.settings.subheading }}</p>
|
|
47
|
+
{%- endif -%}
|
|
48
|
+
|
|
49
|
+
{%- comment -%}
|
|
50
|
+
Flow (corrected 2026-05-11 after live-vendor research):
|
|
51
|
+
1. SCRATCH - foil card visible immediately; visitor scratches it
|
|
52
|
+
2. EMAIL - card stays as trophy; "Won X% off! Email me my code" form appears
|
|
53
|
+
3. REVEALED - code shown with Copy + Shop now (auto-applies via cart URL)
|
|
54
|
+
|
|
55
|
+
The scratch is the dopamine hook that earns the email. Showing the email
|
|
56
|
+
field first kills the mechanic. Pattern matches Knowband, Poptin,
|
|
57
|
+
AppSetup, CI Scratch, Triggerbee, Winify, Superpop.
|
|
58
|
+
{%- endcomment -%}
|
|
59
|
+
|
|
60
|
+
{%- comment -%} STEP 1 (default visible): scratch surface {%- endcomment -%}
|
|
61
|
+
<div class="runwell-scratch__step runwell-scratch__step--scratch" data-runwell-scratch-step="scratch">
|
|
62
|
+
<div class="runwell-scratch__card">
|
|
63
|
+
<div class="runwell-scratch__reveal" data-runwell-scratch-reveal>
|
|
64
|
+
<p class="runwell-scratch__reveal-eyebrow">You won</p>
|
|
65
|
+
<p class="runwell-scratch__reveal-pct" data-runwell-scratch-pct>--%</p>
|
|
66
|
+
<p class="runwell-scratch__reveal-fineprint">off your order</p>
|
|
67
|
+
</div>
|
|
68
|
+
<canvas class="runwell-scratch__canvas" data-runwell-scratch-canvas></canvas>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="runwell-scratch__a11y-reveal"
|
|
72
|
+
data-runwell-scratch-a11y-reveal
|
|
73
|
+
aria-label="Reveal discount (accessibility)"
|
|
74
|
+
>
|
|
75
|
+
Or tap to reveal
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
<p class="runwell-scratch__scratch-prompt">{{ section.settings.scratch_prompt }}</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{%- comment -%} STEP 2: email capture (after scratch reveals the discount amount) {%- endcomment -%}
|
|
82
|
+
<div class="runwell-scratch__step runwell-scratch__step--email" data-runwell-scratch-step="email" hidden>
|
|
83
|
+
<div class="runwell-scratch__card runwell-scratch__card--trophy" data-runwell-scratch-trophy>
|
|
84
|
+
<p class="runwell-scratch__reveal-eyebrow">You won</p>
|
|
85
|
+
<p class="runwell-scratch__reveal-pct" data-runwell-scratch-pct-trophy>--%</p>
|
|
86
|
+
<p class="runwell-scratch__reveal-fineprint">off your order</p>
|
|
87
|
+
</div>
|
|
88
|
+
<p class="runwell-scratch__email-prompt">{{ section.settings.email_prompt }}</p>
|
|
89
|
+
<div class="runwell-scratch__form" data-runwell-scratch-form>
|
|
90
|
+
<input type="hidden" data-runwell-scratch-tags value="newsletter, scratch-popup">
|
|
91
|
+
<label class="visually-hidden" for="runwell-scratch-email">Email</label>
|
|
92
|
+
<input
|
|
93
|
+
id="runwell-scratch-email"
|
|
94
|
+
type="email"
|
|
95
|
+
name="contact[email]"
|
|
96
|
+
placeholder="{{ section.settings.email_placeholder }}"
|
|
97
|
+
required
|
|
98
|
+
data-runwell-scratch-email
|
|
99
|
+
>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
class="runwell-scratch__cta runwell-scratch__cta--email"
|
|
103
|
+
data-runwell-scratch-email-submit
|
|
104
|
+
>
|
|
105
|
+
{{ section.settings.email_cta_text }}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
<p class="runwell-scratch__fineprint">No spam. One use per customer.</p>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{%- comment -%} STEP 3: code revealed + auto-apply CTA {%- endcomment -%}
|
|
112
|
+
<div class="runwell-scratch__step runwell-scratch__step--revealed" data-runwell-scratch-step="revealed" hidden>
|
|
113
|
+
<div class="runwell-scratch__card runwell-scratch__card--trophy">
|
|
114
|
+
<p class="runwell-scratch__reveal-eyebrow">You won</p>
|
|
115
|
+
<p class="runwell-scratch__reveal-pct" data-runwell-scratch-pct-final>--%</p>
|
|
116
|
+
<p class="runwell-scratch__reveal-fineprint">off your order</p>
|
|
117
|
+
</div>
|
|
118
|
+
<p class="runwell-scratch__revealed-heading">{{ section.settings.revealed_heading }}</p>
|
|
119
|
+
<div class="runwell-scratch__code-display">
|
|
120
|
+
<span data-runwell-scratch-code-display>------</span>
|
|
121
|
+
<button type="button" class="runwell-scratch__copy" data-runwell-scratch-copy aria-label="Copy code">Copy</button>
|
|
122
|
+
</div>
|
|
123
|
+
<a
|
|
124
|
+
href="{{ section.settings.reveal_cta_url | default: '/collections/all' }}"
|
|
125
|
+
class="runwell-scratch__cta runwell-scratch__cta--reveal"
|
|
126
|
+
data-runwell-scratch-shop
|
|
127
|
+
>
|
|
128
|
+
{{ section.settings.reveal_cta_text }}
|
|
129
|
+
</a>
|
|
130
|
+
<p class="runwell-scratch__fineprint">{{ section.settings.revealed_subheading }}</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<script src="{{ 'runwell-scratch-popup.js' | asset_url }}" defer="defer"></script>
|
|
136
|
+
|
|
137
|
+
{% schema %}
|
|
138
|
+
{
|
|
139
|
+
"name": "Runwell scratch popup",
|
|
140
|
+
"tag": "section",
|
|
141
|
+
"class": "section-runwell-scratch",
|
|
142
|
+
"settings": [
|
|
143
|
+
{ "type": "header", "content": "Copy" },
|
|
144
|
+
{ "type": "text", "id": "eyebrow", "label": "Eyebrow", "default": "A little surprise" },
|
|
145
|
+
{ "type": "text", "id": "heading", "label": "Heading", "default": "Scratch to reveal your discount" },
|
|
146
|
+
{ "type": "textarea", "id": "subheading", "label": "Subheading", "default": "Scratch the foil card below to see how much you saved." },
|
|
147
|
+
{ "type": "text", "id": "scratch_prompt", "label": "Scratch prompt (under the card)", "default": "Drag or tap to scratch" },
|
|
148
|
+
{ "type": "text", "id": "email_prompt", "label": "Email prompt (after scratch)", "default": "Where should we send your code?" },
|
|
149
|
+
{ "type": "text", "id": "email_placeholder", "label": "Email placeholder", "default": "you@email.com" },
|
|
150
|
+
{ "type": "text", "id": "email_cta_text", "label": "Email CTA", "default": "Email me my code" },
|
|
151
|
+
{ "type": "text", "id": "revealed_heading", "label": "Revealed heading", "default": "Here is your code." },
|
|
152
|
+
{ "type": "textarea", "id": "revealed_subheading", "label": "Revealed fineprint", "default": "Copied and sent to your inbox. One use per customer. Auto-applies at checkout." },
|
|
153
|
+
{ "type": "text", "id": "reveal_cta_text", "label": "Revealed CTA", "default": "Shop now" },
|
|
154
|
+
{ "type": "url", "id": "reveal_cta_url", "label": "Revealed CTA URL (discount auto-appends)" },
|
|
155
|
+
|
|
156
|
+
{ "type": "header", "content": "Tier 1 (most likely)" },
|
|
157
|
+
{ "type": "text", "id": "tier1_code", "label": "Code (must exist as Shopify discount)", "default": "MYSTERY10" },
|
|
158
|
+
{ "type": "range", "id": "tier1_pct", "label": "Percentage off", "min": 1, "max": 50, "step": 1, "default": 10 },
|
|
159
|
+
{ "type": "range", "id": "tier1_prob", "label": "Probability (%)", "min": 0, "max": 100, "step": 5, "default": 60 },
|
|
160
|
+
|
|
161
|
+
{ "type": "header", "content": "Tier 2 (mid)" },
|
|
162
|
+
{ "type": "text", "id": "tier2_code", "label": "Code", "default": "MYSTERY15" },
|
|
163
|
+
{ "type": "range", "id": "tier2_pct", "label": "Percentage off", "min": 1, "max": 50, "step": 1, "default": 15 },
|
|
164
|
+
{ "type": "range", "id": "tier2_prob", "label": "Probability (%)", "min": 0, "max": 100, "step": 5, "default": 30 },
|
|
165
|
+
|
|
166
|
+
{ "type": "header", "content": "Tier 3 (rare)" },
|
|
167
|
+
{ "type": "text", "id": "tier3_code", "label": "Code", "default": "MYSTERY20" },
|
|
168
|
+
{ "type": "range", "id": "tier3_pct", "label": "Percentage off", "min": 1, "max": 50, "step": 1, "default": 20 },
|
|
169
|
+
{ "type": "range", "id": "tier3_prob", "label": "Probability (%)", "min": 0, "max": 100, "step": 5, "default": 10 },
|
|
170
|
+
|
|
171
|
+
{ "type": "header", "content": "Triggers" },
|
|
172
|
+
{ "type": "range", "id": "trigger_delay_sec", "label": "First-visit delay (seconds)", "min": 0, "max": 60, "step": 1, "default": 8 },
|
|
173
|
+
{ "type": "checkbox", "id": "exit_intent", "label": "Also trigger on exit intent", "default": true },
|
|
174
|
+
{ "type": "range", "id": "scratch_threshold_pct", "label": "Foil-erase % before auto-reveal", "min": 30, "max": 90, "step": 5, "default": 60 },
|
|
175
|
+
|
|
176
|
+
{ "type": "header", "content": "Design" },
|
|
177
|
+
{ "type": "color", "id": "foil_color", "label": "Foil color", "default": "#C8B89A" },
|
|
178
|
+
{ "type": "color", "id": "accent_color", "label": "Accent color", "default": "#5B7A3E" }
|
|
179
|
+
],
|
|
180
|
+
"presets": [
|
|
181
|
+
{ "name": "Runwell scratch popup" }
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
{% endschema %}
|