@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.
Files changed (29) hide show
  1. package/lib/init.js +13 -2
  2. package/modules/INDEX.md +3 -3
  3. package/modules/runwell-bundle-system/admin-metafields.json +15 -3
  4. package/modules/runwell-bundle-system/assets/runwell-bundle-quantity-builder.css +383 -0
  5. package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +246 -0
  6. package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +359 -0
  7. package/modules/runwell-bundle-system/module.json +18 -4
  8. package/modules/runwell-bundle-system/qa/v1.5-mobile-qa-checklist.md +103 -0
  9. package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +2 -1
  10. package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +20 -8
  11. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +2 -1
  12. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +2 -1
  13. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +15 -1
  14. package/modules/runwell-bundle-system/sections/runwell-bundle-quantity-builder.liquid +318 -0
  15. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-accordion.liquid +84 -0
  16. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-grid.liquid +72 -0
  17. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-radio.liquid +77 -0
  18. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker.liquid +71 -0
  19. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-summary.liquid +39 -0
  20. package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +15 -2
  21. package/modules/runwell-bundle-system/snippets/runwell-bundle-cart-xsell.liquid +85 -0
  22. package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +8 -0
  23. package/modules/scratch-popup/README.md +88 -0
  24. package/modules/scratch-popup/SPEC.md +120 -0
  25. package/modules/scratch-popup/assets/runwell-scratch-popup.css +315 -0
  26. package/modules/scratch-popup/assets/runwell-scratch-popup.js +367 -0
  27. package/modules/scratch-popup/module.json +128 -0
  28. package/modules/scratch-popup/sections/runwell-scratch-popup.liquid +184 -0
  29. package/package.json +1 -1
@@ -0,0 +1,72 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-byob-picker-grid.liquid.
3
+ Mode C, default layout. Flat checkbox grid of all candidates. Min/max
4
+ picks enforced via JS.
5
+
6
+ Inputs:
7
+ bundle_byob_candidates, bundle_byob_min_picks, bundle_byob_max_picks,
8
+ bundle_byob_required_handles, bundle_pricing_model, bundle_pricing_value.
9
+ {%- endcomment -%}
10
+
11
+ {%- if bundle_byob_candidates and bundle_byob_candidates.size > 0 -%}
12
+ {%- assign required_handles = '' -%}
13
+ {%- if bundle_byob_required_handles -%}
14
+ {%- capture required_handles -%}
15
+ {%- for r in bundle_byob_required_handles -%}{{ r.handle }},{%- endfor -%}
16
+ {%- endcapture -%}
17
+ {%- endif -%}
18
+
19
+ <div
20
+ class="runwell-bundle-system__byob-picker runwell-bundle-system__byob-picker--grid"
21
+ data-runwell-byob-picker
22
+ data-byob-layout="grid"
23
+ data-byob-min="{{ bundle_byob_min_picks }}"
24
+ data-byob-max="{{ bundle_byob_max_picks }}"
25
+ data-byob-pricing-model="{{ bundle_pricing_model }}"
26
+ data-byob-pricing-value="{{ bundle_pricing_value | json | escape }}"
27
+ >
28
+ {%- for candidate in bundle_byob_candidates -%}
29
+ {%- assign is_required = false -%}
30
+ {%- if required_handles contains candidate.handle -%}{%- assign is_required = true -%}{%- endif -%}
31
+ {%- assign is_available = candidate.available | default: true -%}
32
+
33
+ <label
34
+ class="runwell-bundle-system__byob-candidate{% if is_required %} runwell-bundle-system__byob-candidate--required{% endif %}{% unless is_available %} runwell-bundle-system__byob-candidate--unavailable{% endunless %}"
35
+ data-handle="{{ candidate.handle }}"
36
+ data-price="{{ candidate.price }}"
37
+ data-variant-id="{{ candidate.selected_or_first_available_variant.id }}"
38
+ >
39
+ <input
40
+ type="checkbox"
41
+ class="runwell-bundle-system__byob-input"
42
+ name="byob-pick"
43
+ value="{{ candidate.handle }}"
44
+ {% if is_required %}checked disabled{% endif %}
45
+ {% unless is_available %}disabled{% endunless %}
46
+ >
47
+ <span class="runwell-bundle-system__byob-candidate-media">
48
+ {%- if candidate.featured_image -%}
49
+ {{ candidate.featured_image | image_url: width: 240 | image_tag:
50
+ width: 120,
51
+ height: 120,
52
+ loading: 'lazy',
53
+ class: 'runwell-bundle-system__byob-candidate-thumb',
54
+ alt: candidate.title
55
+ }}
56
+ {%- endif -%}
57
+ </span>
58
+ <span class="runwell-bundle-system__byob-candidate-body">
59
+ <span class="runwell-bundle-system__byob-candidate-title">{{ candidate.title }}</span>
60
+ <span class="runwell-bundle-system__byob-candidate-price">{{ candidate.price | money }}</span>
61
+ {%- if is_required -%}
62
+ <span class="runwell-bundle-system__byob-candidate-badge">Always included</span>
63
+ {%- elsif is_available == false -%}
64
+ <span class="runwell-bundle-system__byob-candidate-badge">Sold out</span>
65
+ {%- endif -%}
66
+ </span>
67
+ </label>
68
+ {%- endfor -%}
69
+ </div>
70
+ {%- else -%}
71
+ <p class="runwell-bundle-system__empty">No BYOB candidates configured for this bundle.</p>
72
+ {%- endif -%}
@@ -0,0 +1,77 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-byob-picker-radio.liquid.
3
+ Mode C, one-pick-per-category layout. Total picks equals category count.
4
+ Each category renders as a radio group; total picks across categories
5
+ is the category count exactly.
6
+
7
+ Inputs:
8
+ bundle_byob_candidates, bundle_byob_categories ([{label, handles}]),
9
+ bundle_byob_required_handles, bundle_pricing_model, bundle_pricing_value.
10
+ {%- endcomment -%}
11
+
12
+ {%- if bundle_byob_categories and bundle_byob_categories.size > 0 -%}
13
+ {%- assign required_handles = '' -%}
14
+ {%- if bundle_byob_required_handles -%}
15
+ {%- capture required_handles -%}
16
+ {%- for r in bundle_byob_required_handles -%}{{ r.handle }},{%- endfor -%}
17
+ {%- endcapture -%}
18
+ {%- endif -%}
19
+ {%- assign cat_count = bundle_byob_categories.size -%}
20
+
21
+ <div
22
+ class="runwell-bundle-system__byob-picker runwell-bundle-system__byob-picker--radio"
23
+ data-runwell-byob-picker
24
+ data-byob-layout="radio"
25
+ data-byob-min="{{ cat_count }}"
26
+ data-byob-max="{{ cat_count }}"
27
+ data-byob-pricing-model="{{ bundle_pricing_model }}"
28
+ data-byob-pricing-value="{{ bundle_pricing_value | json | escape }}"
29
+ >
30
+ {%- for category in bundle_byob_categories -%}
31
+ {%- assign cat_idx = forloop.index -%}
32
+ <fieldset class="runwell-bundle-system__byob-radio-group">
33
+ <legend class="runwell-bundle-system__byob-radio-legend">{{ category.label }}</legend>
34
+ {%- for handle in category.handles -%}
35
+ {%- assign candidate = all_products[handle] -%}
36
+ {%- if candidate != blank -%}
37
+ {%- assign is_required = false -%}
38
+ {%- if required_handles contains handle -%}{%- assign is_required = true -%}{%- endif -%}
39
+ <label
40
+ class="runwell-bundle-system__byob-candidate runwell-bundle-system__byob-candidate--radio{% if is_required %} runwell-bundle-system__byob-candidate--required{% endif %}"
41
+ data-handle="{{ candidate.handle }}"
42
+ data-price="{{ candidate.price }}"
43
+ data-variant-id="{{ candidate.selected_or_first_available_variant.id }}"
44
+ data-category="{{ category.label | escape }}"
45
+ >
46
+ <input
47
+ type="radio"
48
+ class="runwell-bundle-system__byob-input"
49
+ name="byob-pick-cat-{{ cat_idx }}"
50
+ value="{{ candidate.handle }}"
51
+ {% if is_required or forloop.first %}checked{% endif %}
52
+ {% unless candidate.available %}disabled{% endunless %}
53
+ >
54
+ <span class="runwell-bundle-system__byob-candidate-media">
55
+ {%- if candidate.featured_image -%}
56
+ {{ candidate.featured_image | image_url: width: 240 | image_tag:
57
+ width: 80,
58
+ height: 80,
59
+ loading: 'lazy',
60
+ class: 'runwell-bundle-system__byob-candidate-thumb',
61
+ alt: candidate.title
62
+ }}
63
+ {%- endif -%}
64
+ </span>
65
+ <span class="runwell-bundle-system__byob-candidate-body">
66
+ <span class="runwell-bundle-system__byob-candidate-title">{{ candidate.title }}</span>
67
+ <span class="runwell-bundle-system__byob-candidate-price">{{ candidate.price | money }}</span>
68
+ </span>
69
+ </label>
70
+ {%- endif -%}
71
+ {%- endfor -%}
72
+ </fieldset>
73
+ {%- endfor -%}
74
+ </div>
75
+ {%- else -%}
76
+ <p class="runwell-bundle-system__empty">Radio layout requires bundle_byob_categories.</p>
77
+ {%- endif -%}
@@ -0,0 +1,71 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-byob-picker.liquid.
3
+ Mode C router. Reads bundle_byob_layout and delegates to one of
4
+ three layout snippets:
5
+ - grid (default)
6
+ - accordion
7
+ - radio (one pick per category)
8
+
9
+ Inputs (all forwarded to the layout snippet):
10
+ product, bundle_pricing_model, bundle_pricing_value,
11
+ bundle_byob_candidates, bundle_byob_min_picks, bundle_byob_max_picks,
12
+ bundle_byob_layout, bundle_byob_categories, bundle_byob_required_handles.
13
+
14
+ Fallback: if layout == 'radio' but no categories are set, falls back
15
+ to grid and emits a console warning client side.
16
+ {%- endcomment -%}
17
+
18
+ {%- assign layout = bundle_byob_layout | default: 'grid' -%}
19
+ {%- if layout == 'radio' and bundle_byob_categories == blank -%}
20
+ {%- assign layout = 'grid' -%}
21
+ {%- endif -%}
22
+
23
+ {% render 'runwell-bundle-byob-summary',
24
+ bundle_pricing_model: bundle_pricing_model,
25
+ bundle_pricing_value: bundle_pricing_value,
26
+ bundle_byob_min_picks: bundle_byob_min_picks,
27
+ bundle_byob_max_picks: bundle_byob_max_picks
28
+ %}
29
+
30
+ {%- form 'product', product, class: 'runwell-bundle-system__form runwell-bundle-system__byob-form', novalidate: 'novalidate' -%}
31
+ {%- if layout == 'grid' -%}
32
+ {% render 'runwell-bundle-byob-picker-grid',
33
+ bundle_byob_candidates: bundle_byob_candidates,
34
+ bundle_byob_min_picks: bundle_byob_min_picks,
35
+ bundle_byob_max_picks: bundle_byob_max_picks,
36
+ bundle_byob_required_handles: bundle_byob_required_handles,
37
+ bundle_pricing_model: bundle_pricing_model,
38
+ bundle_pricing_value: bundle_pricing_value
39
+ %}
40
+ {%- elsif layout == 'accordion' -%}
41
+ {% render 'runwell-bundle-byob-picker-accordion',
42
+ bundle_byob_candidates: bundle_byob_candidates,
43
+ bundle_byob_min_picks: bundle_byob_min_picks,
44
+ bundle_byob_max_picks: bundle_byob_max_picks,
45
+ bundle_byob_categories: bundle_byob_categories,
46
+ bundle_byob_required_handles: bundle_byob_required_handles,
47
+ bundle_pricing_model: bundle_pricing_model,
48
+ bundle_pricing_value: bundle_pricing_value
49
+ %}
50
+ {%- elsif layout == 'radio' -%}
51
+ {% render 'runwell-bundle-byob-picker-radio',
52
+ bundle_byob_candidates: bundle_byob_candidates,
53
+ bundle_byob_categories: bundle_byob_categories,
54
+ bundle_byob_required_handles: bundle_byob_required_handles,
55
+ bundle_pricing_model: bundle_pricing_model,
56
+ bundle_pricing_value: bundle_pricing_value
57
+ %}
58
+ {%- endif -%}
59
+
60
+ <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
61
+
62
+ <button
63
+ type="submit"
64
+ name="add"
65
+ class="runwell-bundle-system__atc button button--primary button--full-width"
66
+ data-runwell-byob-atc
67
+ disabled
68
+ >
69
+ <span class="runwell-bundle-system__atc-label">Add to cart</span>
70
+ </button>
71
+ {%- endform -%}
@@ -0,0 +1,39 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-byob-summary.liquid.
3
+ Live counter + price for Mode C BYOB. Numbers update from JS on every
4
+ selection change. Static fallback on first render shows zeros.
5
+
6
+ Inputs:
7
+ bundle_pricing_model, bundle_pricing_value,
8
+ bundle_byob_min_picks, bundle_byob_max_picks.
9
+ {%- endcomment -%}
10
+
11
+ <div
12
+ class="runwell-bundle-system__byob-summary"
13
+ data-runwell-byob-summary
14
+ data-byob-min="{{ bundle_byob_min_picks }}"
15
+ data-byob-max="{{ bundle_byob_max_picks }}"
16
+ data-byob-pricing-model="{{ bundle_pricing_model }}"
17
+ data-byob-pricing-value="{{ bundle_pricing_value | json | escape }}"
18
+ >
19
+ <p class="runwell-bundle-system__byob-counter" data-runwell-byob-counter>
20
+ <span data-runwell-byob-selected>0</span>
21
+ <span class="runwell-bundle-system__byob-counter-of">of</span>
22
+ <span data-runwell-byob-target>{{ bundle_byob_max_picks }}</span>
23
+ <span class="runwell-bundle-system__byob-counter-label">selected</span>
24
+ </p>
25
+
26
+ <div class="runwell-bundle-system__byob-pricing">
27
+ <span class="runwell-bundle-system__byob-pricing-subtotal" data-runwell-byob-subtotal hidden>
28
+ <s data-runwell-byob-subtotal-amount>{{ 0 | money }}</s>
29
+ </span>
30
+ <span class="runwell-bundle-system__byob-pricing-current" data-runwell-byob-total>{{ 0 | money }}</span>
31
+ <span class="runwell-bundle-system__byob-pricing-badge" data-runwell-byob-savings hidden>
32
+ Save <span data-runwell-byob-savings-amount></span>
33
+ </span>
34
+ </div>
35
+
36
+ <p class="runwell-bundle-system__byob-helper" data-runwell-byob-helper>
37
+ Pick at least <strong>{{ bundle_byob_min_picks }}</strong>{% if bundle_byob_max_picks != bundle_byob_min_picks %}, up to <strong>{{ bundle_byob_max_picks }}</strong>{% endif %} to continue.
38
+ </p>
39
+ </div>
@@ -13,8 +13,21 @@
13
13
 
14
14
  {%- if bundle_product != blank -%}
15
15
  {%- assign card_variant = variant | default: 'default' -%}
16
- {%- assign product = bundle_product -%}
17
- {% render 'runwell-bundle-data', product: bundle_product %}
16
+
17
+ {%- comment -%}
18
+ Inline metafield reads. Liquid `render` is scope-isolated, so calling
19
+ `render 'runwell-bundle-data'` does not leak its assigns back here.
20
+ We read the values directly off the bundle product to keep the card
21
+ self-contained.
22
+ {%- endcomment -%}
23
+ {%- assign card_mf = bundle_product.metafields.runwell -%}
24
+ {%- assign bundle_savings_pct = card_mf.bundle_savings_pct.value | default: 0 -%}
25
+ {%- assign bundle_components = card_mf.bundle_components.value -%}
26
+ {%- assign bundle_cross_supplier = card_mf.bundle_cross_supplier.value | default: false -%}
27
+ {%- assign bundle_supplier_count = card_mf.bundle_supplier_count.value | default: 1 -%}
28
+ {%- assign bundle_price = bundle_product.price -%}
29
+ {%- assign bundle_subtotal = bundle_product.compare_at_price | default: bundle_product.price -%}
30
+
18
31
  {%- assign component_count = 0 -%}
19
32
  {%- if bundle_components and bundle_components.size > 0 -%}
20
33
  {%- assign component_count = bundle_components.size -%}
@@ -0,0 +1,85 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-cart-xsell.liquid (snippet).
3
+
4
+ Surface 4 (bundle xsell), placed INLINE inside a tenant's cart-drawer
5
+ snippet. Companion to sections/runwell-bundle-cart-xsell.liquid for
6
+ tenants whose cart drawer lives outside the section-group renderer
7
+ (e.g. Dawn-shape themes that {% render 'cart-drawer' %} from layout).
8
+
9
+ Drops a self-refreshing slot. The JS hook in assets/runwell-bundle-system.js
10
+ refetches this slot on /cart/{add,change,update,clear} responses.
11
+
12
+ Expected usage inside cart-drawer.liquid:
13
+
14
+ {%- if cart.item_count > 0 -%}
15
+ {% render 'runwell-bundle-cart-xsell',
16
+ fallback_snippet: 'runwell-cart-xsell',
17
+ eyebrow: 'Complete the stack' %}
18
+ {%- endif -%}
19
+
20
+ Inputs (all optional):
21
+ fallback_snippet name of a snippet to render when no bundle match
22
+ eyebrow label above the bundle card (default "Complete the stack")
23
+ cta_label CTA copy passed through to the card (display-only)
24
+ {%- endcomment -%}
25
+
26
+ {%- assign xsell_eyebrow = eyebrow | default: 'Complete the stack' -%}
27
+
28
+ {%- comment -%} Build cart_handles via capture+split: cart.items | map: 'handle' returns empty strings on Dawn-shape themes. {%- endcomment -%}
29
+ {%- capture xsell_cart_handles_csv -%}
30
+ {%- for item in cart.items -%}{{ item.product.handle }}|{%- endfor -%}
31
+ {%- endcapture -%}
32
+ {%- assign xsell_cart_handles = xsell_cart_handles_csv | strip | split: '|' -%}
33
+
34
+ {%- assign xsell_index_instance = shop.metaobjects.bundle_index.values | first -%}
35
+ {%- assign xsell_index = xsell_index_instance.entries.value -%}
36
+
37
+ {%- assign xsell_handle = '' -%}
38
+ {%- assign xsell_score = 0 -%}
39
+
40
+ {%- if xsell_index and xsell_index.bundles -%}
41
+ {%- for entry in xsell_index.bundles -%}
42
+ {%- assign candidate_handle = entry[0] -%}
43
+ {%- assign candidate_components = entry[1].components -%}
44
+ {%- if xsell_cart_handles contains candidate_handle -%}{%- continue -%}{%- endif -%}
45
+
46
+ {%- assign overlap = 0 -%}
47
+ {%- assign component_total = 0 -%}
48
+ {%- for c in candidate_components -%}
49
+ {%- assign component_total = component_total | plus: 1 -%}
50
+ {%- if xsell_cart_handles contains c[0] -%}
51
+ {%- assign overlap = overlap | plus: 1 -%}
52
+ {%- endif -%}
53
+ {%- endfor -%}
54
+
55
+ {%- if overlap > 0 and overlap < component_total and overlap > xsell_score -%}
56
+ {%- assign xsell_score = overlap -%}
57
+ {%- assign xsell_handle = candidate_handle -%}
58
+ {%- endif -%}
59
+ {%- endfor -%}
60
+ {%- endif -%}
61
+
62
+ <div id="CartDrawer-XsellSlot" data-runwell-xsell-slot>
63
+ {%- if xsell_handle != '' -%}
64
+ {%- assign xsell_product = all_products[xsell_handle] -%}
65
+ {%- if xsell_product != blank and xsell_product.handle != blank -%}
66
+ <div class="runwell-bundle-cart-xsell" data-runwell-bundle-xsell>
67
+ <p class="runwell-bundle-cart-xsell__eyebrow">{{ xsell_eyebrow }}</p>
68
+ {% render 'runwell-bundle-card', bundle_product: xsell_product, variant: 'compact' %}
69
+ <p class="runwell-bundle-cart-xsell__hint">
70
+ You already have {{ xsell_score }} of these. Save by getting the full stack.
71
+ </p>
72
+ </div>
73
+ {%- endif -%}
74
+ {%- elsif fallback_snippet != blank -%}
75
+ {%- comment -%}
76
+ No bundle match: tenants opt into a fallback snippet (e.g. simple
77
+ cart-cross-sell). The snippet renders into the same slot so the
78
+ refresh hook can swap it out later when a bundle match arrives.
79
+ {%- endcomment -%}
80
+ {% render fallback_snippet %}
81
+ {%- endif -%}
82
+ </div>
83
+
84
+ {{ 'runwell-bundle-system.css' | asset_url | stylesheet_tag }}
85
+ <script src="{{ 'runwell-bundle-system.js' | asset_url }}" defer="defer"></script>
@@ -32,6 +32,14 @@
32
32
  {%- assign bundle_supplier_count = mf.bundle_supplier_count.value | default: 1 -%}
33
33
  {%- assign bundle_savings_pct_pre = mf.bundle_savings_pct.value -%}
34
34
 
35
+ {%- comment -%} Mode C BYOB metafields (BS-201..BS-203). {%- endcomment -%}
36
+ {%- assign bundle_byob_candidates = mf.bundle_byob_candidates.value -%}
37
+ {%- assign bundle_byob_min_picks = mf.bundle_byob_min_picks.value | default: 1 -%}
38
+ {%- assign bundle_byob_max_picks = mf.bundle_byob_max_picks.value | default: 3 -%}
39
+ {%- assign bundle_byob_layout = mf.bundle_byob_layout.value | default: 'grid' -%}
40
+ {%- assign bundle_byob_categories = mf.bundle_byob_categories.value -%}
41
+ {%- assign bundle_byob_required_handles = mf.bundle_byob_required_handles.value -%}
42
+
35
43
  {%- comment -%} Compute subtotal for multi_product mode by walking components. {%- endcomment -%}
36
44
  {%- assign bundle_subtotal = 0 -%}
37
45
  {%- if bundle_components and bundle_components.size > 0 -%}
@@ -0,0 +1,88 @@
1
+ # scratch-popup
2
+
3
+ Mystery-discount scratch popup. Email capture + foil-card scratch-to-reveal that unlocks a discount code per visitor.
4
+
5
+ **Status:** ready (v0.1.0). Liquid section + CSS + JS implementation complete. See `SPEC.md` for the design doc.
6
+
7
+ ## Install on a tenant store
8
+
9
+ ### 1. Create the 3 discount codes on the store
10
+
11
+ Use the toolkit's helper script (requires the now-live `write_discounts` scope on Runwell Ops):
12
+
13
+ ```
14
+ bash infrastructure/scripts/shopify/shopify-discount-create.sh <store> MYSTERY10 10 \
15
+ --title "Mystery discount 10%" --once-per-customer
16
+
17
+ bash infrastructure/scripts/shopify/shopify-discount-create.sh <store> MYSTERY15 15 \
18
+ --title "Mystery discount 15%" --once-per-customer
19
+
20
+ bash infrastructure/scripts/shopify/shopify-discount-create.sh <store> MYSTERY20 20 \
21
+ --title "Mystery discount 20%" --once-per-customer
22
+ ```
23
+
24
+ Tenant can use any codes / percentages; just make sure the section settings match.
25
+
26
+ ### 2. Drop the section into the theme
27
+
28
+ Add the `Runwell scratch popup` section to a layout that loads on every page (typically the theme.liquid or the index template). Section presets are pre-filled with the MYSTERY10/15/20 defaults.
29
+
30
+ ### 3. Configure (section settings)
31
+
32
+ | Setting group | Notes |
33
+ |---|---|
34
+ | Copy | Eyebrow, heading, subheading, button labels |
35
+ | Tier 1 / 2 / 3 | Code (must match step 1), percentage, probability (must sum to 100) |
36
+ | Triggers | First-visit delay (default 8s), exit intent toggle, scratch threshold (default 60%) |
37
+ | Design | Foil color, accent color |
38
+
39
+ ### 4. Verify
40
+
41
+ - Open the storefront in an incognito window.
42
+ - After the configured delay, the modal appears.
43
+ - Enter an email + click Continue. Email lands in Shopify Customers list with tags `newsletter, scratch-popup`.
44
+ - Scratch the foil. After ~60% erased, the discount auto-reveals.
45
+ - Copy button copies the code. Shop now applies the code at checkout.
46
+
47
+ ## Suppression
48
+
49
+ Session flag stored at `localStorage.runwell_scratch_seen` for 30 days after reveal or dismiss. Visitor sees the popup once per 30-day window per device.
50
+
51
+ ## What it replaces
52
+
53
+ | Replaces | Notes |
54
+ |---|---|
55
+ | Privy / Justuno "Spin to win" | KH (Patrick Franco) and Claspo benchmark both say spin-the-wheel is gimmicky and underperforms. |
56
+ | Fixed "10% off" / WELCOME15 popups | Mystery mechanic creates curiosity; perceived value is higher. |
57
+ | Generic "enter email for updates" forms | Forms with no incentive are dead weight. |
58
+
59
+ ## Why mystery-scratch, not spin-the-wheel
60
+
61
+ Cited in `_knowledge-hub/marketing/2026-03-07-rating-email-capture-popups-for-ecommerce-stores.md`:
62
+
63
+ - Mystery discount: **Great** (creates curiosity, interactive for cold traffic)
64
+ - Spin the wheel: **Avoid** (feels gimmicky now)
65
+ - Free gift, bundle discount: Great
66
+ - Plain "enter email for updates": Avoid
67
+
68
+ Plus Claspo benchmark (779M popup impressions): scratch card 11.29% conversion vs. 3.53% baseline (3.2x lift).
69
+
70
+ ## Scopes required on the app installing this module
71
+
72
+ | Scope | Used for |
73
+ |---|---|
74
+ | `write_discounts` | Generate one-time discount codes per visitor email |
75
+ | `read_discounts` | Look up + dedupe existing codes |
76
+ | `write_customers` | Bind discount code to the captured email |
77
+ | `read_customers` | Email dedup |
78
+
79
+ All four are live on Runwell Ops (`runwell-ops-3`).
80
+
81
+ ## First implementation target
82
+
83
+ Lushi v2 storefront. See `claude-PM/_clients/capital-v/lushi/tickets/mystery-scratch-popup.md`.
84
+
85
+ ## Related modules
86
+
87
+ - `exit-intent` (sibling display-layer module; pattern to lift from)
88
+ - `cart-cross-sell` (also a conversion-category module)
@@ -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)