@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.
- package/modules/_shared/css-tokens/assets/runwell-tokens.css +14 -0
- package/modules/_shared/css-tokens/module.json +13 -0
- package/modules/cart-cross-sell/README.md +32 -0
- package/modules/cart-cross-sell/module.json +15 -0
- package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +40 -0
- package/modules/cart-freeship-progress/README.md +29 -0
- package/modules/cart-freeship-progress/module.json +16 -0
- package/modules/cart-freeship-progress/snippets/runwell-cart-freeship.liquid +27 -0
- package/modules/cart-usps/README.md +22 -0
- package/modules/cart-usps/module.json +17 -0
- package/modules/cart-usps/snippets/runwell-cart-usps.liquid +11 -0
- package/modules/editorial-hero/sections/runwell-video-hero.liquid +9 -3
- package/modules/gift-with-purchase/README.md +36 -0
- package/modules/gift-with-purchase/assets/runwell-gwp.js +42 -0
- package/modules/gift-with-purchase/module.json +32 -0
- package/modules/gift-with-purchase/snippets/runwell-gwp.liquid +30 -0
- package/modules/loyalty-tiers/README.md +45 -0
- package/modules/loyalty-tiers/module.json +40 -0
- package/modules/loyalty-tiers/sections/runwell-tier-card.liquid +86 -0
- package/modules/product-badges/README.md +35 -0
- package/modules/product-badges/module.json +16 -0
- package/modules/product-badges/snippets/runwell-product-badges.liquid +19 -0
- package/modules/quantity-breaks/README.md +33 -0
- package/modules/quantity-breaks/module.json +35 -0
- package/modules/quantity-breaks/snippets/runwell-quantity-breaks.liquid +28 -0
- package/modules/quick-view/README.md +36 -0
- package/modules/quick-view/assets/runwell-quickview.js +153 -0
- package/modules/quick-view/module.json +14 -0
- package/modules/quick-view/snippets/runwell-quickview-modal.liquid +14 -0
- package/modules/quick-view/snippets/runwell-quickview-trigger.liquid +19 -0
- package/modules/subscriptions/README.md +37 -0
- package/modules/subscriptions/module.json +36 -0
- package/modules/subscriptions/snippets/runwell-subscription-picker.liquid +35 -0
- package/modules/wishlist/README.md +48 -0
- package/modules/wishlist/assets/runwell-wishlist.js +112 -0
- package/modules/wishlist/module.json +25 -0
- package/modules/wishlist/sections/runwell-wishlist-page.liquid +35 -0
- package/modules/wishlist/snippets/runwell-wishlist-icon.liquid +17 -0
- package/modules/wishlist/templates/page.wishlist.json +13 -0
- 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, '"') + '" 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 →</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">×</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
|
+
}
|