@runwell/shopify-toolkit 0.1.0 → 0.3.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/bundle-builder/README.md +40 -0
- package/modules/bundle-builder/assets/runwell-bundle-builder.css +383 -0
- package/modules/bundle-builder/module.json +26 -0
- package/modules/bundle-builder/sections/runwell-bundle-builder.liquid +370 -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,42 @@
|
|
|
1
|
+
/* Runwell gift-with-purchase auto-adder. When the cart drawer renders
|
|
2
|
+
a [data-runwell-gwp] element AND the configured gift product is not
|
|
3
|
+
already in cart, this script POSTs to /cart/add.js to add it.
|
|
4
|
+
Quantity is locked to 1. The merchant should also create a
|
|
5
|
+
"Gift" discount in Shopify that makes the gift product 100% off when
|
|
6
|
+
cart total >= threshold (so the auto-added line is free). */
|
|
7
|
+
(function () {
|
|
8
|
+
if (typeof window === 'undefined') return;
|
|
9
|
+
|
|
10
|
+
function autoAddGift() {
|
|
11
|
+
var unlocked = document.querySelector('.runwell-gwp--unlocked');
|
|
12
|
+
if (!unlocked) return;
|
|
13
|
+
var giftHandle = unlocked.getAttribute('data-gift-handle');
|
|
14
|
+
if (!giftHandle) return;
|
|
15
|
+
if (window._runwellGwpAdded) return;
|
|
16
|
+
window._runwellGwpAdded = true;
|
|
17
|
+
|
|
18
|
+
fetch('/products/' + giftHandle + '.js')
|
|
19
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
20
|
+
.then(function (product) {
|
|
21
|
+
if (!product) return;
|
|
22
|
+
var variantId = product.variants && product.variants[0] && product.variants[0].id;
|
|
23
|
+
if (!variantId) return;
|
|
24
|
+
return fetch('/cart/add.js', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ id: variantId, quantity: 1, properties: { _runwell_gift: '1' } })
|
|
28
|
+
});
|
|
29
|
+
})
|
|
30
|
+
.then(function () {
|
|
31
|
+
if (typeof window.fetchCart === 'function') window.fetchCart();
|
|
32
|
+
document.dispatchEvent(new CustomEvent('runwell:cart:refresh'));
|
|
33
|
+
})
|
|
34
|
+
.catch(function () { window._runwellGwpAdded = false; });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (document.readyState === 'loading') {
|
|
38
|
+
document.addEventListener('DOMContentLoaded', autoAddGift);
|
|
39
|
+
} else {
|
|
40
|
+
autoAddGift();
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gift-with-purchase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"category": "conversion",
|
|
5
|
+
"description": "Auto-add a gift product to the cart when total clears a threshold. Shows progress message before unlock and confirmation after. Combine with a 100%-off discount on the gift SKU so it lands free.",
|
|
6
|
+
"files": {
|
|
7
|
+
"snippets": ["snippets/runwell-gwp.liquid"],
|
|
8
|
+
"assets": ["assets/runwell-gwp.js"]
|
|
9
|
+
},
|
|
10
|
+
"config": {
|
|
11
|
+
"schema": {
|
|
12
|
+
"threshold_cents": { "type": "number", "default": 10000, "label": "Spend threshold in cents (e.g. 10000 = $100)" },
|
|
13
|
+
"gift_handle": { "type": "string", "default": "free-gift", "label": "Product handle for the gift" },
|
|
14
|
+
"unlocked_message": { "type": "string", "default": "You earned a free gift." },
|
|
15
|
+
"locked_message_suffix": { "type": "string", "default": "a free gift." }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"admin_steps": [
|
|
19
|
+
{
|
|
20
|
+
"id": "create-gift-product",
|
|
21
|
+
"label": "Create the gift product",
|
|
22
|
+
"url": "https://admin.shopify.com/store/{store_handle}/products/new",
|
|
23
|
+
"summary": "Create a product matching the gift_handle config (default: free-gift). Set a baseline price (e.g. $10) so it shows value. Inventory: enough for forecast monthly volume."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "create-gift-discount",
|
|
27
|
+
"label": "Create the 100% off discount on the gift product",
|
|
28
|
+
"url": "https://admin.shopify.com/store/{store_handle}/discounts/new",
|
|
29
|
+
"summary": "Discount type: Amount off products. Code or Automatic: Automatic preferred. Value: 100%. Applies to: Specific products > select the gift product. Minimum requirement: Subtotal >= $100 (matching threshold_cents). Customer eligibility: All customers. So the auto-added gift line lands free at checkout."
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
Runwell gift-with-purchase. Shows a "free gift unlocked" message in
|
|
3
|
+
the cart drawer when cart total clears the threshold. The actual gift
|
|
4
|
+
product gets auto-added via runwell-gwp.js (cart.js write).
|
|
5
|
+
Render inside cart-drawer.liquid:
|
|
6
|
+
|
|
7
|
+
{% render 'runwell-gwp' %}
|
|
8
|
+
{%- endcomment -%}
|
|
9
|
+
|
|
10
|
+
{%- assign threshold_cents = {{config.threshold_cents}} -%}
|
|
11
|
+
{%- assign gift_handle = '{{config.gift_handle}}' -%}
|
|
12
|
+
{%- assign cart_handles = cart.items | map: 'handle' -%}
|
|
13
|
+
|
|
14
|
+
{%- if cart.total_price >= threshold_cents -%}
|
|
15
|
+
<div class="runwell-gwp runwell-gwp--unlocked" data-runwell-gwp data-gift-handle="{{ gift_handle }}">
|
|
16
|
+
<p class="runwell-gwp__msg">{{config.unlocked_message}}</p>
|
|
17
|
+
{%- unless cart_handles contains gift_handle -%}
|
|
18
|
+
<p class="runwell-gwp__detail">Adding to your bag now.</p>
|
|
19
|
+
{%- endunless -%}
|
|
20
|
+
</div>
|
|
21
|
+
{%- elsif cart.item_count > 0 -%}
|
|
22
|
+
{%- assign remaining = threshold_cents | minus: cart.total_price -%}
|
|
23
|
+
<div class="runwell-gwp runwell-gwp--locked">
|
|
24
|
+
<p class="runwell-gwp__msg">
|
|
25
|
+
Spend <strong>{{ remaining | money }}</strong> more for {{config.locked_message_suffix}}
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
{%- endif -%}
|
|
29
|
+
|
|
30
|
+
<script src="{{ 'runwell-gwp.js' | asset_url }}" defer="defer"></script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# loyalty-tiers
|
|
2
|
+
|
|
3
|
+
Lifetime-spend tier card on the customer account page. Combined with Shopify Flow auto-tagging and tag-targeted discounts, replaces Smile.io / Yotpo Loyalty / LoyaltyLion. App-free.
|
|
4
|
+
|
|
5
|
+
## Why tiers, not points
|
|
6
|
+
|
|
7
|
+
Points-based loyalty needs:
|
|
8
|
+
- Real-time balance updates (Shopify Flow runs are async, lagging 1 to 5 minutes)
|
|
9
|
+
- A custom redemption endpoint that issues unique discount codes
|
|
10
|
+
- Customer education that's expensive at low scale
|
|
11
|
+
|
|
12
|
+
Tiers deliver the same psychological lever (status + reward) using only Shopify primitives: `customer.total_spent`, customer tags, Shopify Flow, tag-targeted discounts.
|
|
13
|
+
|
|
14
|
+
## Files
|
|
15
|
+
|
|
16
|
+
- `sections/runwell-tier-card.liquid`. Tier card section that renders on the customer account template.
|
|
17
|
+
|
|
18
|
+
## How to use
|
|
19
|
+
|
|
20
|
+
1. Run the merchant admin steps in `module.json`:
|
|
21
|
+
- Create Shopify Flow workflows that auto-tag customers as their lifetime spend crosses the thresholds
|
|
22
|
+
- Create tier-targeted discount codes (TIER_INSIDER 10%, TIER_FOUNDER 15%)
|
|
23
|
+
- Add the tier card section to `templates/customers/account.json`
|
|
24
|
+
2. Sync: `runwell-shopify sync`
|
|
25
|
+
3. The tier card renders for any logged-in customer; tier auto-derives from `customer.total_spent`
|
|
26
|
+
|
|
27
|
+
## Default tiers
|
|
28
|
+
|
|
29
|
+
| Tier | Threshold | Perk |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Curator | $0 (everyone) | Free ship over $75, 30-day returns |
|
|
32
|
+
| Insider | $250 | 10% off code (auto-applied via tag), first-look on new arrivals |
|
|
33
|
+
| Founder | $750 | 15% off code, free shipping always, free sample |
|
|
34
|
+
|
|
35
|
+
## Config
|
|
36
|
+
|
|
37
|
+
All tier names, thresholds, and perk percentages are configurable per client. See `module.json` config schema.
|
|
38
|
+
|
|
39
|
+
## Replaces
|
|
40
|
+
|
|
41
|
+
Smile.io, Yotpo Loyalty, Stamped Loyalty, LoyaltyLion (display layer; rewards still issued via Shopify Discounts).
|
|
42
|
+
|
|
43
|
+
## Plan requirement
|
|
44
|
+
|
|
45
|
+
Works on **Shopify Basic and up**. Shopify Flow is included free on all plans (with a daily action limit).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "loyalty-tiers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"category": "customer",
|
|
5
|
+
"description": "Lifetime-spend tier card on the customer account page. Combined with Shopify Flow auto-tagging and tag-targeted discounts, replaces Smile.io / Yotpo Loyalty / LoyaltyLion. App-free.",
|
|
6
|
+
"files": {
|
|
7
|
+
"sections": ["sections/runwell-tier-card.liquid"]
|
|
8
|
+
},
|
|
9
|
+
"config": {
|
|
10
|
+
"schema": {
|
|
11
|
+
"tier_0_name": { "type": "string", "default": "Curator" },
|
|
12
|
+
"tier_1_name": { "type": "string", "default": "Insider" },
|
|
13
|
+
"tier_1_threshold": { "type": "number", "default": 250 },
|
|
14
|
+
"tier_1_perk_pct": { "type": "number", "default": 10 },
|
|
15
|
+
"tier_2_name": { "type": "string", "default": "Founder" },
|
|
16
|
+
"tier_2_threshold": { "type": "number", "default": 750 },
|
|
17
|
+
"tier_2_perk_pct": { "type": "number", "default": 15 }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"admin_steps": [
|
|
21
|
+
{
|
|
22
|
+
"id": "create-tier-tags-and-flows",
|
|
23
|
+
"label": "Create Shopify Flow workflows for tier auto-tagging",
|
|
24
|
+
"url": "https://admin.shopify.com/store/{store_handle}/apps",
|
|
25
|
+
"summary": "Apps > Shopify Flow > Create workflow. Trigger: Order paid. Filter: customer.amount_spent.amount >= 250. Action: Add customer tag runwell-tier-insider + send email. Repeat for >= 750 with runwell-tier-founder."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "create-tier-discounts",
|
|
29
|
+
"label": "Create tier-targeted discount codes",
|
|
30
|
+
"url": "https://admin.shopify.com/store/{store_handle}/discounts/new",
|
|
31
|
+
"summary": "Two discount codes: TIER_INSIDER (10%) targeting customers tagged runwell-tier-insider, TIER_FOUNDER (15%) targeting customers tagged runwell-tier-founder. Both: amount off order, sitewide, no min, unlimited uses, automatic per customer."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "add-tier-card-to-account",
|
|
35
|
+
"label": "Add the tier card to customer account",
|
|
36
|
+
"url": "https://admin.shopify.com/store/{store_handle}/themes",
|
|
37
|
+
"summary": "Edit the customer/account.json template (or via theme editor on the Customer account template). Add the Runwell tier card section before the order list."
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
Runwell loyalty tier card. Renders on the customer account page
|
|
3
|
+
showing current tier + progress to next tier. Drives spend tiers via
|
|
4
|
+
Shopify Flow workflows that auto-tag customers (see admin_steps).
|
|
5
|
+
Replaces Smile.io / Yotpo Loyalty / LoyaltyLion display layer.
|
|
6
|
+
{%- endcomment -%}
|
|
7
|
+
|
|
8
|
+
{%- if customer != blank -%}
|
|
9
|
+
{%- assign spend = customer.total_spent | divided_by: 100.0 -%}
|
|
10
|
+
{%- assign current_tier = section.settings.tier_0_name -%}
|
|
11
|
+
{%- assign current_perk_pct = section.settings.tier_0_perk -%}
|
|
12
|
+
{%- assign next_threshold = section.settings.tier_1_threshold -%}
|
|
13
|
+
{%- assign next_tier = section.settings.tier_1_name -%}
|
|
14
|
+
|
|
15
|
+
{%- if spend >= section.settings.tier_2_threshold -%}
|
|
16
|
+
{%- assign current_tier = section.settings.tier_2_name -%}
|
|
17
|
+
{%- assign current_perk_pct = section.settings.tier_2_perk -%}
|
|
18
|
+
{%- assign next_threshold = 0 -%}
|
|
19
|
+
{%- assign next_tier = blank -%}
|
|
20
|
+
{%- elsif spend >= section.settings.tier_1_threshold -%}
|
|
21
|
+
{%- assign current_tier = section.settings.tier_1_name -%}
|
|
22
|
+
{%- assign current_perk_pct = section.settings.tier_1_perk -%}
|
|
23
|
+
{%- assign next_threshold = section.settings.tier_2_threshold -%}
|
|
24
|
+
{%- assign next_tier = section.settings.tier_2_name -%}
|
|
25
|
+
{%- endif -%}
|
|
26
|
+
|
|
27
|
+
<section class="runwell-tier" aria-labelledby="runwell-tier-heading">
|
|
28
|
+
<div class="runwell-tier__inner">
|
|
29
|
+
<header class="runwell-tier__header">
|
|
30
|
+
<p class="runwell-tier__eyebrow">{{ section.settings.eyebrow }}</p>
|
|
31
|
+
<h2 id="runwell-tier-heading" class="runwell-tier__name">{{ current_tier }}</h2>
|
|
32
|
+
{%- if current_perk_pct > 0 -%}
|
|
33
|
+
<p class="runwell-tier__perk">{{ current_perk_pct }}% off every order, applied automatically at checkout.</p>
|
|
34
|
+
{%- endif -%}
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
{%- if next_tier != blank -%}
|
|
38
|
+
{%- assign remaining = next_threshold | minus: spend -%}
|
|
39
|
+
{%- assign pct = spend | times: 100.0 | divided_by: next_threshold -%}
|
|
40
|
+
{%- if pct > 100 -%}{%- assign pct = 100 -%}{%- endif -%}
|
|
41
|
+
<div class="runwell-tier__progress">
|
|
42
|
+
<p class="runwell-tier__progress-label">${{ remaining | round }} to <strong>{{ next_tier }}</strong></p>
|
|
43
|
+
<div class="runwell-tier__progress-track" aria-hidden="true">
|
|
44
|
+
<div class="runwell-tier__progress-fill" style="width: {{ pct }}%"></div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
{%- else -%}
|
|
48
|
+
<p class="runwell-tier__topped">You're at the top tier. Welcome to the inside.</p>
|
|
49
|
+
{%- endif -%}
|
|
50
|
+
|
|
51
|
+
<ul class="runwell-tier__perks-list">
|
|
52
|
+
<li>Free shipping over $75</li>
|
|
53
|
+
<li>30-day satisfaction promise</li>
|
|
54
|
+
{%- if customer.tags contains 'runwell-tier-insider' or customer.tags contains 'runwell-tier-founder' -%}
|
|
55
|
+
<li>First-look access on new arrivals</li>
|
|
56
|
+
{%- endif -%}
|
|
57
|
+
{%- if customer.tags contains 'runwell-tier-founder' -%}
|
|
58
|
+
<li>Free shipping always</li>
|
|
59
|
+
<li>Free sample with every order</li>
|
|
60
|
+
{%- endif -%}
|
|
61
|
+
</ul>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
{%- endif -%}
|
|
65
|
+
|
|
66
|
+
{% schema %}
|
|
67
|
+
{
|
|
68
|
+
"name": "Runwell tier card",
|
|
69
|
+
"tag": "section",
|
|
70
|
+
"class": "section-runwell-tier",
|
|
71
|
+
"settings": [
|
|
72
|
+
{ "type": "text", "id": "eyebrow", "label": "Eyebrow", "default": "Your tier" },
|
|
73
|
+
{ "type": "text", "id": "tier_0_name", "label": "Tier 0 name", "default": "Curator" },
|
|
74
|
+
{ "type": "number", "id": "tier_0_perk", "label": "Tier 0 perk %", "default": 0 },
|
|
75
|
+
{ "type": "text", "id": "tier_1_name", "label": "Tier 1 name", "default": "Insider" },
|
|
76
|
+
{ "type": "number", "id": "tier_1_threshold", "label": "Tier 1 spend threshold ($)", "default": 250 },
|
|
77
|
+
{ "type": "number", "id": "tier_1_perk", "label": "Tier 1 perk %", "default": 10 },
|
|
78
|
+
{ "type": "text", "id": "tier_2_name", "label": "Tier 2 name", "default": "Founder" },
|
|
79
|
+
{ "type": "number", "id": "tier_2_threshold", "label": "Tier 2 spend threshold ($)", "default": 750 },
|
|
80
|
+
{ "type": "number", "id": "tier_2_perk", "label": "Tier 2 perk %", "default": 15 }
|
|
81
|
+
],
|
|
82
|
+
"presets": [
|
|
83
|
+
{ "name": "Runwell tier card" }
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
{% endschema %}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# product-badges
|
|
2
|
+
|
|
3
|
+
Tag-driven product card badges (Best seller / New / Editor's pick). Renders inside the card-product snippet.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
- `snippets/runwell-product-badges.liquid`
|
|
8
|
+
|
|
9
|
+
## How to use
|
|
10
|
+
|
|
11
|
+
In `snippets/card-product.liquid` near the existing `card__badge` block:
|
|
12
|
+
|
|
13
|
+
```liquid
|
|
14
|
+
{% render 'runwell-product-badges', product: card_product %}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Tags
|
|
18
|
+
|
|
19
|
+
- Tag a product with `best-seller` to render the Best seller badge
|
|
20
|
+
- Tag with `new` for the New badge
|
|
21
|
+
- Tag with `editor-pick` for the Editor's pick badge
|
|
22
|
+
|
|
23
|
+
Tags are added in Shopify admin: Products > {product} > Tags.
|
|
24
|
+
|
|
25
|
+
## Config
|
|
26
|
+
|
|
27
|
+
| Key | Default | Notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `label_best_seller` | `Best seller` | Localise per locale |
|
|
30
|
+
| `label_new` | `New` | Localise per locale |
|
|
31
|
+
| `label_editor_pick` | `Editor's pick` | Localise per locale |
|
|
32
|
+
|
|
33
|
+
## Replaces
|
|
34
|
+
|
|
35
|
+
Badge widgets in Vitals, BSS, and similar.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "product-badges",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"category": "social-proof",
|
|
5
|
+
"description": "Tag-driven badges on product cards (Best seller, New, Editor's pick). Native, no app.",
|
|
6
|
+
"files": {
|
|
7
|
+
"snippets": ["snippets/runwell-product-badges.liquid"]
|
|
8
|
+
},
|
|
9
|
+
"config": {
|
|
10
|
+
"schema": {
|
|
11
|
+
"label_best_seller": { "type": "string", "default": "Best seller" },
|
|
12
|
+
"label_new": { "type": "string", "default": "New" },
|
|
13
|
+
"label_editor_pick": { "type": "string", "default": "Editor's pick" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|