@runwell/shopify-toolkit 0.1.0 → 0.2.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 (40) hide show
  1. package/modules/_shared/css-tokens/assets/runwell-tokens.css +14 -0
  2. package/modules/_shared/css-tokens/module.json +13 -0
  3. package/modules/cart-cross-sell/README.md +32 -0
  4. package/modules/cart-cross-sell/module.json +15 -0
  5. package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +40 -0
  6. package/modules/cart-freeship-progress/README.md +29 -0
  7. package/modules/cart-freeship-progress/module.json +16 -0
  8. package/modules/cart-freeship-progress/snippets/runwell-cart-freeship.liquid +27 -0
  9. package/modules/cart-usps/README.md +22 -0
  10. package/modules/cart-usps/module.json +17 -0
  11. package/modules/cart-usps/snippets/runwell-cart-usps.liquid +11 -0
  12. package/modules/editorial-hero/sections/runwell-video-hero.liquid +9 -3
  13. package/modules/gift-with-purchase/README.md +36 -0
  14. package/modules/gift-with-purchase/assets/runwell-gwp.js +42 -0
  15. package/modules/gift-with-purchase/module.json +32 -0
  16. package/modules/gift-with-purchase/snippets/runwell-gwp.liquid +30 -0
  17. package/modules/loyalty-tiers/README.md +45 -0
  18. package/modules/loyalty-tiers/module.json +40 -0
  19. package/modules/loyalty-tiers/sections/runwell-tier-card.liquid +86 -0
  20. package/modules/product-badges/README.md +35 -0
  21. package/modules/product-badges/module.json +16 -0
  22. package/modules/product-badges/snippets/runwell-product-badges.liquid +19 -0
  23. package/modules/quantity-breaks/README.md +33 -0
  24. package/modules/quantity-breaks/module.json +35 -0
  25. package/modules/quantity-breaks/snippets/runwell-quantity-breaks.liquid +28 -0
  26. package/modules/quick-view/README.md +36 -0
  27. package/modules/quick-view/assets/runwell-quickview.js +153 -0
  28. package/modules/quick-view/module.json +14 -0
  29. package/modules/quick-view/snippets/runwell-quickview-modal.liquid +14 -0
  30. package/modules/quick-view/snippets/runwell-quickview-trigger.liquid +19 -0
  31. package/modules/subscriptions/README.md +37 -0
  32. package/modules/subscriptions/module.json +36 -0
  33. package/modules/subscriptions/snippets/runwell-subscription-picker.liquid +35 -0
  34. package/modules/wishlist/README.md +48 -0
  35. package/modules/wishlist/assets/runwell-wishlist.js +112 -0
  36. package/modules/wishlist/module.json +25 -0
  37. package/modules/wishlist/sections/runwell-wishlist-page.liquid +35 -0
  38. package/modules/wishlist/snippets/runwell-wishlist-icon.liquid +17 -0
  39. package/modules/wishlist/templates/page.wishlist.json +13 -0
  40. package/package.json +1 -1
@@ -0,0 +1,19 @@
1
+ {%- comment -%}
2
+ Runwell product badges. Renders one badge per product card based on
3
+ product tags. Use inside snippets/card-product.liquid:
4
+
5
+ {% render 'runwell-product-badges', product: card_product %}
6
+
7
+ Tag-driven for v1 (best-seller, new, editor-pick). Future variants
8
+ could read from a metafield to support custom labels per product.
9
+ {%- endcomment -%}
10
+
11
+ {%- if product != blank -%}
12
+ {%- if product.tags contains 'best-seller' -%}
13
+ <span class="badge badge--bottom-left runwell-badge runwell-badge--best">{{config.label_best_seller}}</span>
14
+ {%- elsif product.tags contains 'new' -%}
15
+ <span class="badge badge--bottom-left runwell-badge runwell-badge--new">{{config.label_new}}</span>
16
+ {%- elsif product.tags contains 'editor-pick' -%}
17
+ <span class="badge badge--bottom-left runwell-badge runwell-badge--editor">{{config.label_editor_pick}}</span>
18
+ {%- endif -%}
19
+ {%- endif -%}
@@ -0,0 +1,33 @@
1
+ # quantity-breaks
2
+
3
+ Tiered quantity-discount display on the PDP. Reads breaks from product metafield `runwell.quantity_breaks`. Replaces volume discount apps for the display layer.
4
+
5
+ ## Files
6
+
7
+ - `snippets/runwell-quantity-breaks.liquid`. PDP snippet rendering the tier table.
8
+
9
+ ## How to use
10
+
11
+ 1. Run admin steps in `module.json`:
12
+ - Define the `runwell.quantity_breaks` metafield (Settings > Custom data > Products)
13
+ - Set the metafield value per product (JSON: `[{qty: 2, discount_pct: 10}, ...]`)
14
+ - Create a Shopify Function discount that mirrors the metafield to actually apply the discount at checkout
15
+ 2. Render the snippet in `sections/main-product.liquid` near the price block:
16
+ ```liquid
17
+ {% render 'runwell-quantity-breaks', product: product %}
18
+ ```
19
+
20
+ ## Display only by default
21
+
22
+ This module is display-only. The actual discount enforcement is a Shopify Functions discount the merchant configures separately. Without the function, the table renders but the cart will not apply the savings.
23
+
24
+ ## Replaces
25
+
26
+ Volume discount apps (Bold Quantity Breaks, Discount Master) for the display layer. The discount engine is replaced by Shopify Functions (free, native).
27
+
28
+ ## Config
29
+
30
+ | Key | Default | Notes |
31
+ |---|---|---|
32
+ | `eyebrow` | `Buy more, save more` | Tag above the tier list |
33
+ | `fineprint` | `Discount applied automatically at checkout.` | Microcopy under the tier list |
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "quantity-breaks",
3
+ "version": "0.1.0",
4
+ "category": "catalog",
5
+ "description": "Tiered quantity-discount display on the PDP. Reads breaks from product metafield runwell.quantity_breaks. The actual discount is applied via Shopify Functions configured per merchant.",
6
+ "files": {
7
+ "snippets": ["snippets/runwell-quantity-breaks.liquid"]
8
+ },
9
+ "config": {
10
+ "schema": {
11
+ "eyebrow": { "type": "string", "default": "Buy more, save more" },
12
+ "fineprint": { "type": "string", "default": "Discount applied automatically at checkout." }
13
+ }
14
+ },
15
+ "admin_steps": [
16
+ {
17
+ "id": "define-breaks-metafield",
18
+ "label": "Define the runwell.quantity_breaks product metafield",
19
+ "url": "https://admin.shopify.com/store/{store_handle}/settings/custom_data/products/metafields",
20
+ "summary": "Settings > Custom data > Products > Add definition. Namespace: runwell. Key: quantity_breaks. Type: JSON. Validation: must be a list of {qty, discount_pct}."
21
+ },
22
+ {
23
+ "id": "set-product-breaks",
24
+ "label": "Set quantity breaks per product",
25
+ "url": "https://admin.shopify.com/store/{store_handle}/products",
26
+ "summary": "Edit each product where quantity discounts apply. In the Metafields section, set runwell.quantity_breaks to a JSON value like: [{\"qty\": 2, \"discount_pct\": 10}, {\"qty\": 3, \"discount_pct\": 15}]."
27
+ },
28
+ {
29
+ "id": "create-discount-function",
30
+ "label": "Create a Shopify Functions discount that mirrors the metafield",
31
+ "url": "https://shopify.dev/docs/apps/build/discounts/sample-apps",
32
+ "summary": "Build a Shopify Function (or use an existing free Functions app like 'Volume Discount Builder') that reads runwell.quantity_breaks per line item and applies the matching discount. Without this, the metafield is display-only."
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,28 @@
1
+ {%- comment -%}
2
+ Runwell quantity breaks display. Shows tiered "buy more, save more"
3
+ pricing on the PDP. Reads from product metafield runwell.quantity_breaks
4
+ which is a JSON list of {qty, discount_pct}. The actual discount is
5
+ applied via Shopify Functions (configured per merchant) so the cart
6
+ reflects the savings at checkout.
7
+ Render inside main-product.liquid:
8
+ {% render 'runwell-quantity-breaks', product: product %}
9
+ {%- endcomment -%}
10
+
11
+ {%- if product != blank -%}
12
+ {%- assign breaks = product.metafields.runwell.quantity_breaks.value -%}
13
+ {%- if breaks != blank and breaks.size > 0 -%}
14
+ <div class="runwell-qty-breaks" aria-label="Quantity savings">
15
+ <p class="runwell-qty-breaks__eyebrow">{{config.eyebrow}}</p>
16
+ <ul class="runwell-qty-breaks__list">
17
+ {%- for tier in breaks -%}
18
+ <li class="runwell-qty-breaks__item">
19
+ <span class="runwell-qty-breaks__qty">{{ tier.qty }}+</span>
20
+ <span class="runwell-qty-breaks__discount">{{ tier.discount_pct }}% off</span>
21
+ {%- assign tier_price = product.price | times: 100.0 | minus: product.price | times: tier.discount_pct | divided_by: 100.0 -%}
22
+ </li>
23
+ {%- endfor -%}
24
+ </ul>
25
+ <p class="runwell-qty-breaks__fineprint">{{config.fineprint}}</p>
26
+ </div>
27
+ {%- endif -%}
28
+ {%- endif -%}
@@ -0,0 +1,36 @@
1
+ # quick-view
2
+
3
+ Modal product quick-view. Click an eye icon on a product card; modal opens with image, variant picker, qty, ATC. Replaces Globo Quick View, Pagefly QV, Vitals QV.
4
+
5
+ ## Files
6
+
7
+ - `assets/runwell-quickview.js`. Core modal + variant logic.
8
+ - `snippets/runwell-quickview-modal.liquid`. Single shared modal shell rendered once at body end.
9
+ - `snippets/runwell-quickview-trigger.liquid`. Eye icon trigger to drop into card-product.
10
+
11
+ ## How to use
12
+
13
+ 1. Sync: `runwell-shopify sync`
14
+ 2. Render the modal shell once (typically at the end of `<body>` in `layout/theme.liquid`):
15
+ ```liquid
16
+ {% render 'runwell-quickview-modal' %}
17
+ ```
18
+ 3. Add the trigger inside `snippets/card-product.liquid` near the product image:
19
+ ```liquid
20
+ {% render 'runwell-quickview-trigger', handle: card_product.handle %}
21
+ ```
22
+ 4. Style the modal in your brand stylesheet using the `.runwell-quickview` selectors.
23
+
24
+ ## Behaviour
25
+
26
+ - Click trigger: fetches `/products/{handle}.js` (cached after first call)
27
+ - Renders title, price, short description, variant picker, ATC button
28
+ - Variant change updates price + variant id
29
+ - ATC posts to `/cart/add.js`, closes modal, dispatches `runwell:cart:open` event
30
+ - ESC + backdrop click close
31
+ - Single variant: no picker, just ATC
32
+ - All-variants-out-of-stock: ATC disabled "Sold out"
33
+
34
+ ## Replaces
35
+
36
+ Globo Quick View, Pagefly QV, Vitals QV.
@@ -0,0 +1,153 @@
1
+ /* Runwell quick-view modal. Click an eye icon on a product card to
2
+ open a modal with image, variant picker, qty, ATC. ATC posts to
3
+ /cart/add.js and opens the cart drawer on success.
4
+ Replaces Globo Quick View / Pagefly QV / Vitals QV. */
5
+ (function () {
6
+ if (typeof window === 'undefined') return;
7
+
8
+ var modal = null;
9
+ var cache = {};
10
+ var lastTrigger = null;
11
+
12
+ function ensureModal() {
13
+ if (modal) return modal;
14
+ modal = document.querySelector('[data-runwell-quickview-modal]');
15
+ return modal;
16
+ }
17
+
18
+ function open(handle, trigger) {
19
+ var m = ensureModal();
20
+ if (!m) return;
21
+ lastTrigger = trigger;
22
+ m.setAttribute('aria-hidden', 'false');
23
+ m.classList.add('is-open');
24
+ document.body.classList.add('runwell-quickview-open');
25
+ var body = m.querySelector('[data-runwell-quickview-body]');
26
+ if (body) body.innerHTML = '<div class="runwell-quickview__loading">Loading...</div>';
27
+
28
+ var p = cache[handle] ? Promise.resolve(cache[handle]) :
29
+ fetch('/products/' + handle + '.js').then(function (r) { return r.json(); }).then(function (j) { cache[handle] = j; return j; });
30
+ p.then(function (product) { renderProduct(body, product); }).catch(function () {
31
+ if (body) body.innerHTML = '<p class="runwell-quickview__error">Could not load this product.</p>';
32
+ });
33
+ }
34
+
35
+ function close() {
36
+ var m = ensureModal();
37
+ if (!m) return;
38
+ m.setAttribute('aria-hidden', 'true');
39
+ m.classList.remove('is-open');
40
+ document.body.classList.remove('runwell-quickview-open');
41
+ if (lastTrigger) lastTrigger.focus();
42
+ }
43
+
44
+ function renderProduct(host, product) {
45
+ if (!host || !product) return;
46
+ var firstImage = product.images && product.images[0];
47
+ var imageHtml = firstImage ? '<img src="' + firstImage + '" alt="' + (product.title || '').replace(/"/g, '&quot;') + '" loading="lazy">' : '';
48
+ var price = (product.price / 100).toFixed(2);
49
+ var availableVariants = (product.variants || []).filter(function (v) { return v.available; });
50
+ var firstVariant = availableVariants[0] || product.variants[0];
51
+ var soldOut = !firstVariant || !firstVariant.available;
52
+
53
+ var variantPickerHtml = '';
54
+ if (product.variants && product.variants.length > 1) {
55
+ variantPickerHtml = '<div class="runwell-quickview__variants">' +
56
+ product.variants.map(function (v) {
57
+ var sel = v.id === firstVariant.id ? ' is-selected' : '';
58
+ var disabled = v.available ? '' : ' disabled';
59
+ return '<button type="button" class="runwell-quickview__variant' + sel + '" data-variant-id="' + v.id + '"' + disabled + '>' + v.title + '</button>';
60
+ }).join('') +
61
+ '</div>';
62
+ }
63
+
64
+ host.innerHTML =
65
+ '<div class="runwell-quickview__media">' + imageHtml + '</div>' +
66
+ '<div class="runwell-quickview__details">' +
67
+ '<h2 class="runwell-quickview__title">' + product.title + '</h2>' +
68
+ '<p class="runwell-quickview__price" data-runwell-quickview-price>$' + price + '</p>' +
69
+ (product.description ? '<div class="runwell-quickview__desc">' + product.description.slice(0, 220) + (product.description.length > 220 ? '...' : '') + '</div>' : '') +
70
+ variantPickerHtml +
71
+ '<form class="runwell-quickview__form" data-runwell-quickview-form>' +
72
+ '<input type="hidden" name="id" value="' + (firstVariant ? firstVariant.id : '') + '" data-runwell-quickview-variant-input>' +
73
+ '<button type="submit" class="runwell-quickview__atc"' + (soldOut ? ' disabled' : '') + '>' +
74
+ (soldOut ? 'Sold out' : 'Add to cart') +
75
+ '</button>' +
76
+ '<a class="runwell-quickview__pdp" href="/products/' + product.handle + '">View full product &rarr;</a>' +
77
+ '</form>' +
78
+ '</div>';
79
+
80
+ bindVariantPicker(host, product);
81
+ bindAtc(host);
82
+ }
83
+
84
+ function bindVariantPicker(host, product) {
85
+ var buttons = host.querySelectorAll('[data-variant-id]');
86
+ var input = host.querySelector('[data-runwell-quickview-variant-input]');
87
+ var priceEl = host.querySelector('[data-runwell-quickview-price]');
88
+ buttons.forEach(function (b) {
89
+ b.addEventListener('click', function (e) {
90
+ e.preventDefault();
91
+ if (b.disabled) return;
92
+ buttons.forEach(function (x) { x.classList.remove('is-selected'); });
93
+ b.classList.add('is-selected');
94
+ var id = parseInt(b.getAttribute('data-variant-id'), 10);
95
+ if (input) input.value = id;
96
+ var v = product.variants.find(function (x) { return x.id === id; });
97
+ if (v && priceEl) priceEl.textContent = '$' + (v.price / 100).toFixed(2);
98
+ });
99
+ });
100
+ }
101
+
102
+ function bindAtc(host) {
103
+ var form = host.querySelector('[data-runwell-quickview-form]');
104
+ if (!form) return;
105
+ form.addEventListener('submit', function (e) {
106
+ e.preventDefault();
107
+ var fd = new FormData(form);
108
+ fetch('/cart/add.js', {
109
+ method: 'POST',
110
+ body: fd,
111
+ headers: { 'Accept': 'application/json' }
112
+ }).then(function (r) { return r.json(); }).then(function () {
113
+ close();
114
+ var openCart = document.querySelector('cart-drawer');
115
+ if (openCart && typeof openCart.open === 'function') openCart.open();
116
+ else document.dispatchEvent(new CustomEvent('runwell:cart:open'));
117
+ }).catch(function () {});
118
+ });
119
+ }
120
+
121
+ function bindTriggers() {
122
+ document.querySelectorAll('[data-runwell-quickview]').forEach(function (btn) {
123
+ if (btn.dataset.runwellQvBound === '1') return;
124
+ btn.dataset.runwellQvBound = '1';
125
+ btn.addEventListener('click', function (e) {
126
+ e.preventDefault();
127
+ var handle = btn.getAttribute('data-handle');
128
+ if (handle) open(handle, btn);
129
+ });
130
+ });
131
+ }
132
+
133
+ function bindModalControls() {
134
+ var m = ensureModal();
135
+ if (!m) return;
136
+ m.querySelectorAll('[data-runwell-quickview-close]').forEach(function (el) {
137
+ el.addEventListener('click', close);
138
+ });
139
+ document.addEventListener('keydown', function (e) {
140
+ if (e.key === 'Escape' && m.classList.contains('is-open')) close();
141
+ });
142
+ }
143
+
144
+ function init() {
145
+ bindTriggers();
146
+ bindModalControls();
147
+ }
148
+ if (document.readyState === 'loading') {
149
+ document.addEventListener('DOMContentLoaded', init);
150
+ } else {
151
+ init();
152
+ }
153
+ })();
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "quick-view",
3
+ "version": "0.1.0",
4
+ "category": "conversion",
5
+ "description": "Modal product quick-view triggered from product cards. Variant picker + ATC. App-free replacement for Globo QV / Pagefly QV / Vitals QV.",
6
+ "files": {
7
+ "assets": ["assets/runwell-quickview.js"],
8
+ "snippets": [
9
+ "snippets/runwell-quickview-modal.liquid",
10
+ "snippets/runwell-quickview-trigger.liquid"
11
+ ]
12
+ },
13
+ "config": { "schema": {} }
14
+ }
@@ -0,0 +1,14 @@
1
+ {%- comment -%}
2
+ Runwell quick-view modal shell. Render once at end of layout/theme.liquid:
3
+ {% render 'runwell-quickview-modal' %}
4
+ {%- endcomment -%}
5
+
6
+ <div class="runwell-quickview" data-runwell-quickview-modal aria-hidden="true" role="dialog" aria-labelledby="runwell-qv-title">
7
+ <div class="runwell-quickview__backdrop" data-runwell-quickview-close></div>
8
+ <div class="runwell-quickview__panel">
9
+ <button class="runwell-quickview__close" type="button" data-runwell-quickview-close aria-label="Close">&times;</button>
10
+ <div class="runwell-quickview__body" data-runwell-quickview-body></div>
11
+ </div>
12
+ </div>
13
+
14
+ <script src="{{ 'runwell-quickview.js' | asset_url }}" defer="defer"></script>
@@ -0,0 +1,19 @@
1
+ {%- comment -%}
2
+ Runwell quick-view trigger button. Render inside card-product.liquid
3
+ near the product image:
4
+
5
+ {% render 'runwell-quickview-trigger', handle: card_product.handle %}
6
+ {%- endcomment -%}
7
+
8
+ <button
9
+ type="button"
10
+ class="runwell-quickview-trigger"
11
+ data-runwell-quickview
12
+ data-handle="{{ handle }}"
13
+ aria-label="Quick view"
14
+ >
15
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
16
+ <path d="M12 5C7 5 3 12 3 12s4 7 9 7 9-7 9-7-4-7-9-7zm0 11a4 4 0 110-8 4 4 0 010 8z" fill="currentColor"/>
17
+ <circle cx="12" cy="12" r="2" fill="#FFFFFF"/>
18
+ </svg>
19
+ </button>
@@ -0,0 +1,37 @@
1
+ # subscriptions
2
+
3
+ Brand-styled subscribe-and-save picker on top of Shopify's native Selling Plan API. Replaces Recharge / Bold Subscriptions / Appstle / Loop for the storefront display layer. Customer subscription management uses Shopify's native customer account.
4
+
5
+ ## Files
6
+
7
+ - `snippets/runwell-subscription-picker.liquid`. Radio picker between one-time and subscribe-and-save.
8
+
9
+ ## How to use
10
+
11
+ 1. Run the merchant admin steps in `module.json` (install Shopify's free Subscriptions app, create a Selling Plan Group)
12
+ 2. Render the picker in `sections/main-product.liquid` BEFORE the `buy_buttons` block:
13
+ ```liquid
14
+ {% render 'runwell-subscription-picker', product: product %}
15
+ ```
16
+ 3. The picker emits a `selling_plan` form input that Dawn's buy-buttons snippet picks up automatically when present
17
+ 4. Style `.runwell-sub` selectors in your brand stylesheet
18
+
19
+ ## Config
20
+
21
+ | Key | Default | Notes |
22
+ |---|---|---|
23
+ | `one_time_label` | `One-time purchase` | Label for the no-subscription option |
24
+ | `subscribe_label` | `Subscribe and save` | Label for the subscription option |
25
+ | `fineprint` | `Cancel anytime. Skip a delivery from your account.` | Microcopy under the picker |
26
+
27
+ ## Why no app
28
+
29
+ Shopify ships native subscription primitives (Selling Plan API, native customer-account subscription tab) since 2021. The free Shopify Subscriptions app is the canonical UI for managing Selling Plan Groups. We use that for management; this module handles the storefront PDP picker.
30
+
31
+ ## Replaces
32
+
33
+ Recharge, Bold Subscriptions, Appstle, Loop (display layer only; the management UX is Shopify's native account tab).
34
+
35
+ ## Plan requirement
36
+
37
+ Works on **Shopify Basic and up**. The free Shopify Subscriptions app is required.
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "subscriptions",
3
+ "version": "0.1.0",
4
+ "category": "catalog",
5
+ "description": "Brand-styled subscribe-and-save picker that wraps Shopify's native Selling Plan API. Replaces Recharge / Bold Subscriptions / Appstle for the storefront display layer; subscription management still uses Shopify's native customer account.",
6
+ "files": {
7
+ "snippets": ["snippets/runwell-subscription-picker.liquid"]
8
+ },
9
+ "config": {
10
+ "schema": {
11
+ "one_time_label": { "type": "string", "default": "One-time purchase" },
12
+ "subscribe_label": { "type": "string", "default": "Subscribe and save" },
13
+ "fineprint": { "type": "string", "default": "Cancel anytime. Skip a delivery from your account." }
14
+ }
15
+ },
16
+ "admin_steps": [
17
+ {
18
+ "id": "install-shopify-subscriptions",
19
+ "label": "Install Shopify's free Subscriptions app",
20
+ "url": "https://apps.shopify.com/shopify-subscriptions",
21
+ "summary": "Install the Subscriptions app made by Shopify (free, first-party). Required to expose Selling Plan Groups."
22
+ },
23
+ {
24
+ "id": "create-selling-plan-group",
25
+ "label": "Create a Selling Plan Group",
26
+ "url": "https://admin.shopify.com/store/{store_handle}/apps",
27
+ "summary": "Apps > Subscriptions > Create plan. Name 'Subscribe and save'. Add delivery options every 30/60/90 days. Apply 15% discount on subscription orders. Apply to selected products."
28
+ },
29
+ {
30
+ "id": "enable-customer-account-tab",
31
+ "label": "Verify Subscriptions tab on customer account",
32
+ "url": "https://admin.shopify.com/store/{store_handle}/settings/customer_accounts",
33
+ "summary": "Settings > Customer accounts > Customize. Confirm the Subscriptions section is enabled. Customers will see skip/cancel/change frequency from /account."
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,35 @@
1
+ {%- comment -%}
2
+ Runwell subscription picker. Brand-styled radio between one-time and
3
+ subscribe-and-save when a product has selling_plan_groups configured.
4
+ Render inside main-product.liquid where the buy buttons live, BEFORE
5
+ the buy_buttons block:
6
+
7
+ {% render 'runwell-subscription-picker', product: product %}
8
+
9
+ Requires:
10
+ - Shopify Subscriptions app installed (free, made by Shopify)
11
+ - At least one Selling Plan Group attached to the product
12
+ {%- endcomment -%}
13
+
14
+ {%- if product != blank and product.selling_plan_groups.size > 0 -%}
15
+ {%- assign group = product.selling_plan_groups[0] -%}
16
+ <fieldset class="runwell-sub" data-runwell-subscription-picker>
17
+ <legend class="visually-hidden">Purchase type</legend>
18
+ <label class="runwell-sub__option">
19
+ <input type="radio" name="selling_plan" value="" checked>
20
+ <span class="runwell-sub__title">{{config.one_time_label}}</span>
21
+ <span class="runwell-sub__price">{{ product.price | money }}</span>
22
+ </label>
23
+ {%- for plan in group.selling_plans -%}
24
+ <label class="runwell-sub__option runwell-sub__option--featured">
25
+ <input type="radio" name="selling_plan" value="{{ plan.id }}">
26
+ <span class="runwell-sub__title">
27
+ {{config.subscribe_label}}
28
+ <span class="runwell-sub__discount">{{ plan.price_adjustments[0].value }}% off</span>
29
+ </span>
30
+ <span class="runwell-sub__detail">{{ plan.name }}</span>
31
+ </label>
32
+ {%- endfor -%}
33
+ <p class="runwell-sub__fineprint">{{config.fineprint}}</p>
34
+ </fieldset>
35
+ {%- endif -%}
@@ -0,0 +1,48 @@
1
+ # wishlist
2
+
3
+ Native wishlist using localStorage. Replaces Wishlist Plus, Wishlist King, Globo Wishlist. App-free.
4
+
5
+ ## Files
6
+
7
+ - `assets/runwell-wishlist.js`. Core storage + UI logic. ~120 LOC, no dependencies.
8
+ - `snippets/runwell-wishlist-icon.liquid`. Heart button to drop into card-product or main-product.
9
+ - `sections/runwell-wishlist-page.liquid`. Section that hydrates the dedicated wishlist page.
10
+ - `templates/page.wishlist.json`. Page template wiring the section to the `/pages/wishlist` URL.
11
+
12
+ ## How to use
13
+
14
+ 1. Sync the module: `runwell-shopify sync`
15
+ 2. Add the heart icon wherever a product is listed:
16
+ ```liquid
17
+ {% render 'runwell-wishlist-icon', handle: card_product.handle %}
18
+ ```
19
+ 3. Create the Wishlist page in admin (see admin_steps in module.json):
20
+ - Pages > Add page > Title "Wishlist" > Visible > Theme template "page.wishlist" > Save
21
+ 4. Visit `/pages/wishlist` to see the saved items hydrate from localStorage.
22
+
23
+ ## Config
24
+
25
+ | Key | Default | Notes |
26
+ |---|---|---|
27
+ | `max_items` | `100` | Max items kept in localStorage. Excess trimmed FIFO. |
28
+
29
+ ## Storage
30
+
31
+ | Surface | Mechanism |
32
+ |---|---|
33
+ | Guest user | `localStorage["runwell_wishlist"] = ["handle-1", ...]` |
34
+ | Logged-in customer | localStorage only in v0.1; metafield sync is v0.2 (requires App Proxy) |
35
+
36
+ ## Events
37
+
38
+ The module dispatches `runwell:wishlist:change` on `document` whenever an item is added or removed. Useful if you want to update a header counter:
39
+
40
+ ```js
41
+ document.addEventListener('runwell:wishlist:change', function (e) {
42
+ console.log('count:', e.detail.count);
43
+ });
44
+ ```
45
+
46
+ ## Replaces
47
+
48
+ Wishlist Plus, Wishlist King, Globo Wishlist (display layer; the metafield sync option in those apps is the only feature not yet built).
@@ -0,0 +1,112 @@
1
+ /* Runwell wishlist. localStorage-backed for guests; reads/writes
2
+ customer metafield lushi.wishlist when logged in (writes require an
3
+ App Proxy; v1 ships localStorage only, sync to metafield is v0.2).
4
+ Replaces Wishlist Plus, Wishlist King, Globo Wishlist. */
5
+ (function () {
6
+ if (typeof window === 'undefined') return;
7
+ var KEY = 'runwell_wishlist';
8
+ var MAX = 100;
9
+
10
+ function read() {
11
+ try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; }
12
+ }
13
+ function write(list) {
14
+ try { localStorage.setItem(KEY, JSON.stringify(list.slice(0, MAX))); } catch (e) {}
15
+ }
16
+ function has(handle) { return read().indexOf(handle) !== -1; }
17
+ function add(handle) {
18
+ var list = read().filter(function (h) { return h !== handle; });
19
+ list.unshift(handle);
20
+ write(list);
21
+ fire('add', handle);
22
+ }
23
+ function remove(handle) {
24
+ var list = read().filter(function (h) { return h !== handle; });
25
+ write(list);
26
+ fire('remove', handle);
27
+ }
28
+ function toggle(handle) {
29
+ if (has(handle)) { remove(handle); return false; }
30
+ add(handle); return true;
31
+ }
32
+ function fire(action, handle) {
33
+ var event = new CustomEvent('runwell:wishlist:change', { detail: { action: action, handle: handle, count: read().length } });
34
+ document.dispatchEvent(event);
35
+ }
36
+
37
+ function bindButtons() {
38
+ document.querySelectorAll('[data-runwell-wishlist]').forEach(function (btn) {
39
+ if (btn.dataset.runwellWishlistBound === '1') return;
40
+ btn.dataset.runwellWishlistBound = '1';
41
+ var handle = btn.getAttribute('data-handle');
42
+ if (!handle) return;
43
+ function refresh() {
44
+ if (has(handle)) {
45
+ btn.setAttribute('aria-pressed', 'true');
46
+ btn.classList.add('is-saved');
47
+ } else {
48
+ btn.setAttribute('aria-pressed', 'false');
49
+ btn.classList.remove('is-saved');
50
+ }
51
+ }
52
+ btn.addEventListener('click', function (e) {
53
+ e.preventDefault();
54
+ toggle(handle);
55
+ refresh();
56
+ });
57
+ refresh();
58
+ });
59
+ }
60
+
61
+ function renderPage() {
62
+ var host = document.querySelector('[data-runwell-wishlist-page]');
63
+ if (!host) return;
64
+ var handles = read();
65
+ var grid = host.querySelector('[data-runwell-wishlist-grid]');
66
+ var emptyState = host.querySelector('[data-runwell-wishlist-empty]');
67
+ if (!handles.length) {
68
+ if (emptyState) emptyState.style.display = '';
69
+ if (grid) grid.style.display = 'none';
70
+ return;
71
+ }
72
+ if (emptyState) emptyState.style.display = 'none';
73
+ if (grid) grid.style.display = '';
74
+
75
+ Promise.all(handles.map(function (h) {
76
+ return fetch('/products/' + h + '.js').then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
77
+ })).then(function (products) {
78
+ if (!grid) return;
79
+ grid.innerHTML = products.filter(Boolean).map(function (p) {
80
+ var img = p.featured_image ? '<img src="' + p.featured_image + '" alt="' + p.title + '" loading="lazy">' : '';
81
+ var price = (p.price / 100).toFixed(2);
82
+ return '<a class="runwell-wishlist__card" href="/products/' + p.handle + '">' +
83
+ '<div class="runwell-wishlist__media">' + img + '</div>' +
84
+ '<div class="runwell-wishlist__title">' + p.title + '</div>' +
85
+ '<div class="runwell-wishlist__price">$' + price + '</div>' +
86
+ '<button type="button" class="runwell-wishlist__remove" data-handle="' + p.handle + '" aria-label="Remove from wishlist">Remove</button>' +
87
+ '</a>';
88
+ }).join('');
89
+
90
+ grid.querySelectorAll('.runwell-wishlist__remove').forEach(function (btn) {
91
+ btn.addEventListener('click', function (e) {
92
+ e.preventDefault();
93
+ remove(btn.getAttribute('data-handle'));
94
+ renderPage();
95
+ });
96
+ });
97
+ });
98
+ }
99
+
100
+ window.runwellWishlist = { has: has, add: add, remove: remove, toggle: toggle, list: read };
101
+
102
+ function init() {
103
+ bindButtons();
104
+ renderPage();
105
+ }
106
+ document.addEventListener('runwell:wishlist:change', bindButtons);
107
+ if (document.readyState === 'loading') {
108
+ document.addEventListener('DOMContentLoaded', init);
109
+ } else {
110
+ init();
111
+ }
112
+ })();
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "wishlist",
3
+ "version": "0.1.0",
4
+ "category": "customer",
5
+ "description": "Native wishlist using localStorage. Heart icon on cards + dedicated /pages/wishlist page. v1 ships localStorage; metafield sync via App Proxy is v0.2. Replaces Wishlist Plus, Wishlist King, Globo Wishlist.",
6
+ "files": {
7
+ "assets": ["assets/runwell-wishlist.js"],
8
+ "snippets": ["snippets/runwell-wishlist-icon.liquid"],
9
+ "sections": ["sections/runwell-wishlist-page.liquid"],
10
+ "templates": ["templates/page.wishlist.json"]
11
+ },
12
+ "config": {
13
+ "schema": {
14
+ "max_items": { "type": "number", "default": 100 }
15
+ }
16
+ },
17
+ "admin_steps": [
18
+ {
19
+ "id": "create-wishlist-page",
20
+ "label": "Create the wishlist page in Shopify admin",
21
+ "url": "https://admin.shopify.com/store/{store_handle}/pages/new",
22
+ "summary": "Create a Page with title 'Wishlist'. Set visibility Visible. In the Theme template dropdown, select 'page.wishlist'. Save."
23
+ }
24
+ ]
25
+ }