@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.
@@ -0,0 +1,120 @@
1
+ # scratch-popup module (spec)
2
+
3
+ > Status: spec (no code yet). Validated 2026-05-11 against `_knowledge-hub/marketing/2026-03-07-rating-email-capture-popups-for-ecommerce-stores.md` and the Claspo popup benchmark (11.29% scratch card vs 3.53% standard popup; 3.2x lift). First implementation target: Lushi v2 storefront.
4
+
5
+ ## What
6
+
7
+ A gamified email-capture popup. Visitor sees a foil-textured card with a scratch-to-reveal mechanic. They enter their email, scratch the foil with mouse or finger, and a discount code is revealed underneath. Replaces the "type email get 15% off" mechanic with something more interactive and curiosity-driven.
8
+
9
+ Sibling module to `exit-intent` (same display-layer wiring). The two can run together: scratch-popup as the primary email-capture path, exit-intent as the fallback for visitors who dismiss the scratch card.
10
+
11
+ ## Why
12
+
13
+ Cited in our internal Knowledge Hub (Patrick Franco, Shopify email marketing): "mystery discount creates a lot of curiosity and is very interactive for cold traffic". Same source rates spin-the-wheel as gimmicky and to be avoided. Mystery-scratch occupies the "great" tier alongside bundle discounts and free-gift popups.
14
+
15
+ External benchmark (Claspo, 779M popup impressions analyzed): scratch card average conversion 11.29% vs. 3.53% for standard static popups (3.2x lift). Treat as a directional ceiling, not a guarantee.
16
+
17
+ ## UX flow
18
+
19
+ 1. Trigger: visitor lands and meets one of:
20
+ - First-time visitor + 8s on site (configurable)
21
+ - Exit intent (mouse to top of viewport on desktop, scroll-up 30% on mobile)
22
+ - Tenant-configurable manual trigger (e.g., "Try your luck" CTA button)
23
+ 2. Modal opens. Foil card with prompt "Scratch to reveal your discount". Email field above, foil below.
24
+ 3. Email gate first (configurable; default ON). Visitor enters email + clicks Continue. Email stored in Shopify Customer + Klaviyo (if wired).
25
+ 4. Foil layer activates. Visitor drags mouse or swipes finger across the foil. Pixel by pixel erase via HTML5 Canvas `destination-out` composite.
26
+ 5. Once a threshold percentage of pixels is erased (default 60%), the foil auto-fades and reveals the discount code.
27
+ 6. Discount code displays + auto-copies to clipboard + sends to the email entered. Code is ALSO bound to that email so it cannot be shared.
28
+ 7. CTA: "Shop now" (closes modal, applies discount via cart). Visitor can also dismiss.
29
+ 8. Session flag (`runwell_scratch_played=true`) stored. Visitor cannot re-trigger the scratch on the same session OR with the same email.
30
+
31
+ ## Discount tiers (probability table, configurable)
32
+
33
+ Default schema:
34
+ ```jsonc
35
+ [
36
+ { "code_prefix": "MYSTERY10", "percentage": 10, "probability": 0.60 },
37
+ { "code_prefix": "MYSTERY15", "percentage": 15, "probability": 0.30 },
38
+ { "code_prefix": "MYSTERY20", "percentage": 20, "probability": 0.10 }
39
+ ]
40
+ ```
41
+
42
+ - Total probability must sum to 1.0 (validated on config load).
43
+ - Each tenant can override.
44
+ - Server picks the tier on email submit, generates a unique code via Shopify Admin `discountCodeBasicCreate` (scope `write_discounts`, already live on Runwell Ops), and returns the code to the client.
45
+ - Code is one-use, bound to the customer email (Shopify `discountCustomerSelection: { customers: { add: [<customer-id>] } }`), 30-day expiry.
46
+
47
+ ## Tenant config (runwell.config.json)
48
+
49
+ ```jsonc
50
+ {
51
+ "modules": {
52
+ "scratch-popup": {
53
+ "enabled": true,
54
+ "triggers": {
55
+ "first_visit_delay_sec": 8,
56
+ "exit_intent": true,
57
+ "manual_cta_selector": null
58
+ },
59
+ "email_gate": true,
60
+ "scratch_threshold_pct": 60,
61
+ "tiers": [
62
+ { "code_prefix": "MYSTERY10", "percentage": 10, "probability": 0.60 },
63
+ { "code_prefix": "MYSTERY15", "percentage": 15, "probability": 0.30 },
64
+ { "code_prefix": "MYSTERY20", "percentage": 20, "probability": 0.10 }
65
+ ],
66
+ "expiry_days": 30,
67
+ "copy": {
68
+ "heading": "Scratch to reveal your discount",
69
+ "subheading": "One scratch, one code, just for you.",
70
+ "email_placeholder": "you@email.com",
71
+ "email_cta": "Continue",
72
+ "reveal_cta": "Shop now",
73
+ "post_reveal_subheading": "Code copied. Also sent to your inbox."
74
+ },
75
+ "design": {
76
+ "foil_color": "#C8B89A",
77
+ "foil_texture_url": null,
78
+ "accent_color": "#5B7A3E",
79
+ "card_radius_px": 16
80
+ },
81
+ "klaviyo_list_id": null
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## Technical components
88
+
89
+ | Piece | Implementation |
90
+ |---|---|
91
+ | Liquid section | `sections/runwell-scratch-popup.liquid`. Renders the modal shell, hidden by default. Pulls config from `runwell.config.json` via the toolkit's standard config-injection pattern. |
92
+ | CSS | `assets/runwell-scratch-popup.css`. Modal backdrop, card layout, foil container, responsive breakpoints, reduced-motion fallback. |
93
+ | JS | `assets/runwell-scratch-popup.js`. Trigger logic, modal lifecycle, email validation, fetch to backend for code, Canvas scratch interaction with pointer + touch events, threshold detection, reveal animation, session-flag storage. |
94
+ | Backend (Shopify Function or API endpoint) | Email -> tier selection (weighted random) -> `discountCodeBasicCreate` mutation -> return code. Lives in the toolkit's standard backend wiring (look at `exit-intent` for the pattern). |
95
+ | Accessibility fallback | If reduced-motion or no canvas support: render a "Reveal" button that bypasses scratch and goes straight to step 7. |
96
+ | Analytics | Fire toolkit's standard `runwell:popup:shown`, `runwell:popup:email_submit`, `runwell:popup:scratch_started`, `runwell:popup:scratch_revealed`, `runwell:popup:code_applied` events. Tenant analytics integration picks them up. |
97
+
98
+ ## Open questions
99
+
100
+ - Email gate before or after scratch? Default ON (before) reduces abuse and improves email quality. Tenant can disable.
101
+ - One scratch per email or one per session? Default: one per email per 30 days, server-enforced.
102
+ - Klaviyo integration: same wiring as `exit-intent` (KLAVIYO_PUBLIC_KEY in env). Or per-tenant API key in tenant config.
103
+ - Discount stacking: should scratch code stack with auto-applied promotions? Default: no, but tenant can override per discount tier.
104
+
105
+ ## Out of scope
106
+
107
+ - Random non-discount rewards (free gift, free shipping, $X off). Future iteration if mystery-discount converts.
108
+ - Multi-step quizzes ("answer 3 questions then scratch"). Different module, larger spec.
109
+ - Storefront wheel/spin variants (KH says skip; we agree).
110
+
111
+ ## First implementation target
112
+
113
+ Lushi v2 storefront. After the bundle-system rollout lands (BS-601..606). Coordinate with Lushi v2 launch sequencing.
114
+
115
+ ## Related
116
+
117
+ - `_clients/capital-v/lushi/tickets/mystery-scratch-popup.md` (the originating ticket)
118
+ - `_knowledge-hub/marketing/2026-03-07-rating-email-capture-popups-for-ecommerce-stores.md` (validation)
119
+ - `modules/exit-intent/` (wiring pattern; sibling module)
120
+ - `infrastructure/scripts/shopify/shopify-discount-create.sh` (server-side discount code generation)
@@ -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; }