@runwell/shopify-toolkit 0.21.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/lib/init.js +13 -2
- package/modules/INDEX.md +3 -3
- package/modules/runwell-bundle-system/admin-metafields.json +15 -3
- package/modules/runwell-bundle-system/assets/runwell-bundle-quantity-builder.css +383 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +246 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +359 -0
- package/modules/runwell-bundle-system/module.json +18 -4
- package/modules/runwell-bundle-system/qa/v1.5-mobile-qa-checklist.md +103 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +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 +15 -1
- package/modules/runwell-bundle-system/sections/runwell-bundle-quantity-builder.liquid +318 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-accordion.liquid +84 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-grid.liquid +72 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-radio.liquid +77 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker.liquid +71 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-summary.liquid +39 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +15 -2
- package/modules/runwell-bundle-system/snippets/runwell-bundle-cart-xsell.liquid +85 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +8 -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,315 @@
|
|
|
1
|
+
/* Runwell scratch popup. Modal layout with email gate -> foil scratch surface -> revealed code.
|
|
2
|
+
Two CSS variables drive the brand colors: --runwell-scratch-foil (the scratch overlay) and
|
|
3
|
+
--runwell-scratch-accent (CTAs, highlights). Set on the root element via inline style from
|
|
4
|
+
the section's color settings. */
|
|
5
|
+
|
|
6
|
+
.runwell-scratch {
|
|
7
|
+
position: fixed;
|
|
8
|
+
inset: 0;
|
|
9
|
+
z-index: 999;
|
|
10
|
+
display: none;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: center;
|
|
13
|
+
padding: 1.5rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.runwell-scratch.is-open { display: flex; }
|
|
17
|
+
|
|
18
|
+
.runwell-scratch__backdrop {
|
|
19
|
+
position: absolute;
|
|
20
|
+
inset: 0;
|
|
21
|
+
background: rgba(0, 0, 0, 0.6);
|
|
22
|
+
backdrop-filter: blur(4px);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.runwell-scratch__panel {
|
|
26
|
+
position: relative;
|
|
27
|
+
background: #FFFFFF;
|
|
28
|
+
color: var(--runwell-primary, #1A1A1A);
|
|
29
|
+
max-width: 460px;
|
|
30
|
+
width: 100%;
|
|
31
|
+
padding: 2.5rem 2rem 2rem;
|
|
32
|
+
border-radius: 12px;
|
|
33
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
|
34
|
+
text-align: center;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.runwell-scratch__close {
|
|
38
|
+
position: absolute;
|
|
39
|
+
top: 0.5rem;
|
|
40
|
+
right: 0.8rem;
|
|
41
|
+
background: transparent;
|
|
42
|
+
border: 0;
|
|
43
|
+
font-size: 1.8rem;
|
|
44
|
+
font-weight: 300;
|
|
45
|
+
color: currentColor;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
line-height: 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.runwell-scratch__eyebrow {
|
|
51
|
+
font-size: 0.75rem;
|
|
52
|
+
letter-spacing: 0.2em;
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
font-weight: 700;
|
|
55
|
+
margin: 0 0 0.6rem 0;
|
|
56
|
+
opacity: 0.6;
|
|
57
|
+
color: var(--runwell-scratch-accent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.runwell-scratch__heading {
|
|
61
|
+
font-family: var(--font-heading-family, serif);
|
|
62
|
+
font-style: normal;
|
|
63
|
+
font-weight: 400;
|
|
64
|
+
font-size: 1.7rem;
|
|
65
|
+
line-height: 1.15;
|
|
66
|
+
margin: 0 0 0.8rem 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.runwell-scratch__subheading {
|
|
70
|
+
font-size: 0.95rem;
|
|
71
|
+
line-height: 1.55;
|
|
72
|
+
margin: 0 0 1.6rem 0;
|
|
73
|
+
opacity: 0.8;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.runwell-scratch__step { animation: runwellScratchFade 0.25s ease-out both; }
|
|
77
|
+
@keyframes runwellScratchFade {
|
|
78
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
79
|
+
to { opacity: 1; transform: translateY(0); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---------- Step 1: email gate ---------- */
|
|
83
|
+
.runwell-scratch__form {
|
|
84
|
+
display: flex;
|
|
85
|
+
gap: 0.5rem;
|
|
86
|
+
flex-wrap: wrap;
|
|
87
|
+
margin-bottom: 0.8rem;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.runwell-scratch__form input[type="email"] {
|
|
91
|
+
flex: 1;
|
|
92
|
+
min-width: 200px;
|
|
93
|
+
padding: 0.85rem 1rem;
|
|
94
|
+
border: 1px solid rgba(0, 0, 0, 0.25);
|
|
95
|
+
border-radius: 6px;
|
|
96
|
+
font-size: 0.95rem;
|
|
97
|
+
background: #FFFFFF;
|
|
98
|
+
transition: border-color 0.2s ease;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.runwell-scratch__form input[type="email"].is-invalid {
|
|
102
|
+
border-color: #C0392B;
|
|
103
|
+
animation: runwellScratchShake 0.4s ease;
|
|
104
|
+
}
|
|
105
|
+
@keyframes runwellScratchShake {
|
|
106
|
+
0%, 100% { transform: translateX(0); }
|
|
107
|
+
20%, 60% { transform: translateX(-4px); }
|
|
108
|
+
40%, 80% { transform: translateX(4px); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.runwell-scratch__email-prompt {
|
|
112
|
+
font-size: 1rem;
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
margin: 0 0 0.6rem 0;
|
|
115
|
+
color: var(--runwell-scratch-accent, #1A1A1A);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.runwell-scratch__cta {
|
|
119
|
+
background: var(--runwell-scratch-accent, #1A1A1A);
|
|
120
|
+
color: #FFFFFF;
|
|
121
|
+
border: 0;
|
|
122
|
+
padding: 0.85rem 1.4rem;
|
|
123
|
+
font-weight: 700;
|
|
124
|
+
font-size: 0.95rem;
|
|
125
|
+
border-radius: 6px;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
text-decoration: none;
|
|
128
|
+
display: inline-block;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.runwell-scratch__cta:hover { filter: brightness(0.92); }
|
|
132
|
+
|
|
133
|
+
.runwell-scratch__cta--reveal {
|
|
134
|
+
display: block;
|
|
135
|
+
margin: 1rem auto 0;
|
|
136
|
+
max-width: 280px;
|
|
137
|
+
text-align: center;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.runwell-scratch__fineprint {
|
|
141
|
+
font-size: 0.75rem;
|
|
142
|
+
margin-top: 0.4rem;
|
|
143
|
+
opacity: 0.55;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ---------- Step 1: scratch surface ---------- */
|
|
147
|
+
.runwell-scratch__scratch-prompt {
|
|
148
|
+
font-size: 0.85rem;
|
|
149
|
+
margin: 0.6rem 0 0 0;
|
|
150
|
+
opacity: 0.6;
|
|
151
|
+
letter-spacing: 0.05em;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.runwell-scratch__card {
|
|
155
|
+
position: relative;
|
|
156
|
+
width: 100%;
|
|
157
|
+
height: 220px;
|
|
158
|
+
margin: 0.6rem 0 0;
|
|
159
|
+
border-radius: 12px;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
background: #FFFFFF;
|
|
162
|
+
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
|
|
163
|
+
user-select: none;
|
|
164
|
+
touch-action: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Trophy variant: the same card, shown smaller after the scratch so it sits as
|
|
168
|
+
a header above the email form / code display. No canvas, just the reveal. */
|
|
169
|
+
.runwell-scratch__card--trophy {
|
|
170
|
+
height: 110px;
|
|
171
|
+
background: #FFFFFF;
|
|
172
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.10);
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
align-items: center;
|
|
176
|
+
justify-content: center;
|
|
177
|
+
gap: 0.1rem;
|
|
178
|
+
margin: 0 0 1rem;
|
|
179
|
+
color: var(--runwell-scratch-accent, #1A1A1A);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.runwell-scratch__card--trophy .runwell-scratch__reveal-pct {
|
|
183
|
+
font-size: 1.9rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.runwell-scratch__reveal {
|
|
187
|
+
position: absolute;
|
|
188
|
+
inset: 0;
|
|
189
|
+
display: flex;
|
|
190
|
+
flex-direction: column;
|
|
191
|
+
align-items: center;
|
|
192
|
+
justify-content: center;
|
|
193
|
+
gap: 0.2rem;
|
|
194
|
+
background: #FFFFFF;
|
|
195
|
+
color: var(--runwell-scratch-accent, #1A1A1A);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.runwell-scratch__reveal-eyebrow {
|
|
199
|
+
font-size: 0.65rem;
|
|
200
|
+
letter-spacing: 0.2em;
|
|
201
|
+
text-transform: uppercase;
|
|
202
|
+
font-weight: 700;
|
|
203
|
+
margin: 0;
|
|
204
|
+
opacity: 0.65;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.runwell-scratch__reveal-pct {
|
|
208
|
+
font-size: 3rem;
|
|
209
|
+
font-weight: 800;
|
|
210
|
+
margin: 0;
|
|
211
|
+
line-height: 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.runwell-scratch__reveal-fineprint {
|
|
215
|
+
font-size: 0.75rem;
|
|
216
|
+
margin: 0;
|
|
217
|
+
opacity: 0.6;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.runwell-scratch__canvas {
|
|
221
|
+
position: absolute;
|
|
222
|
+
inset: 0;
|
|
223
|
+
width: 100%;
|
|
224
|
+
height: 100%;
|
|
225
|
+
cursor: grab;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.runwell-scratch__canvas:active { cursor: grabbing; }
|
|
229
|
+
|
|
230
|
+
.runwell-scratch__card.is-revealed .runwell-scratch__canvas {
|
|
231
|
+
animation: runwellScratchFadeOut 0.4s ease-out forwards;
|
|
232
|
+
pointer-events: none;
|
|
233
|
+
}
|
|
234
|
+
@keyframes runwellScratchFadeOut {
|
|
235
|
+
to { opacity: 0; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.runwell-scratch__a11y-reveal {
|
|
239
|
+
position: absolute;
|
|
240
|
+
bottom: 0.5rem;
|
|
241
|
+
right: 0.6rem;
|
|
242
|
+
background: rgba(255, 255, 255, 0.85);
|
|
243
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
244
|
+
font-size: 0.7rem;
|
|
245
|
+
padding: 0.25rem 0.55rem;
|
|
246
|
+
border-radius: 4px;
|
|
247
|
+
cursor: pointer;
|
|
248
|
+
z-index: 2;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ---------- Step 3: revealed ---------- */
|
|
252
|
+
.runwell-scratch__revealed-heading {
|
|
253
|
+
font-family: var(--font-heading-family, serif);
|
|
254
|
+
font-size: 1.5rem;
|
|
255
|
+
font-weight: 400;
|
|
256
|
+
margin: 0 0 0.4rem 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.runwell-scratch__revealed-subheading {
|
|
260
|
+
font-size: 0.9rem;
|
|
261
|
+
margin: 0 0 1rem 0;
|
|
262
|
+
opacity: 0.75;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.runwell-scratch__code-display {
|
|
266
|
+
display: flex;
|
|
267
|
+
align-items: center;
|
|
268
|
+
justify-content: center;
|
|
269
|
+
gap: 0.5rem;
|
|
270
|
+
margin: 0 0 0.8rem 0;
|
|
271
|
+
padding: 0.8rem 1rem;
|
|
272
|
+
background: rgba(0, 0, 0, 0.03);
|
|
273
|
+
border-radius: 8px;
|
|
274
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
275
|
+
font-size: 1.3rem;
|
|
276
|
+
font-weight: 700;
|
|
277
|
+
letter-spacing: 0.08em;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.runwell-scratch__copy {
|
|
281
|
+
background: transparent;
|
|
282
|
+
border: 1px solid var(--runwell-scratch-accent, #1A1A1A);
|
|
283
|
+
color: var(--runwell-scratch-accent, #1A1A1A);
|
|
284
|
+
padding: 0.35rem 0.7rem;
|
|
285
|
+
border-radius: 4px;
|
|
286
|
+
font-size: 0.75rem;
|
|
287
|
+
font-weight: 600;
|
|
288
|
+
cursor: pointer;
|
|
289
|
+
text-transform: uppercase;
|
|
290
|
+
letter-spacing: 0.1em;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.runwell-scratch__copy.is-copied {
|
|
294
|
+
background: var(--runwell-scratch-accent, #1A1A1A);
|
|
295
|
+
color: #FFFFFF;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ---------- Reduced motion ---------- */
|
|
299
|
+
@media (prefers-reduced-motion: reduce) {
|
|
300
|
+
.runwell-scratch__step,
|
|
301
|
+
.runwell-scratch__card.is-revealed .runwell-scratch__canvas {
|
|
302
|
+
animation: none;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ---------- Mobile ---------- */
|
|
307
|
+
@media (max-width: 540px) {
|
|
308
|
+
.runwell-scratch { padding: 1rem; }
|
|
309
|
+
.runwell-scratch__panel { padding: 2rem 1.3rem 1.6rem; }
|
|
310
|
+
.runwell-scratch__heading { font-size: 1.45rem; }
|
|
311
|
+
.runwell-scratch__card { height: 150px; }
|
|
312
|
+
.runwell-scratch__reveal-pct { font-size: 2.1rem; }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
body.runwell-scratch-open { overflow: hidden; }
|
|
@@ -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
|
+
})();
|