A cart can carry discount codes, automatic discounts, bundles, Buy X Get Y offers and free gifts, products can be on sale or priced by quantity, and product pages can list the offers a product qualifies for. This page covers what a theme shows for each, which fields to read and which forms to post.
Context#
Add cart to the ctx_needs of every section that shows the cart: the cart page and the cart drawer. Product pages read product; order pages read customer. All discount fields below are on ctx.cart unless a section says otherwise. Money fields are presented money ({amount, currency_code}): print them with | money. Fields that start with formatted_ are already formatted in the shopper's currency and language: print them as they are.
The discount code box#
Build the box with the discount_code_apply form and one code field. Each applied code gets a discount_code_remove form with the code in a hidden field. Both work without JavaScript; the storefront bridge submits them in the background and refreshes the sections marked data-tf-refresh. Fields, limits and responses: Cart actions.
Gift Cards are redeemed at checkout, not in the cart. Checkout's Discount code or gift card field reads a code with a Gift Card code's shape (32 letters and digits, with or without hyphens) as a Gift Card and every other code as a discount. Merchants can't create discount codes with that shape, so a code your cart box applies is never a Gift Card; a Gift Card code entered in the cart box is refused as a discount code, and the shopper redeems it at checkout.
{%- for code in cart.coupon_codes -%}
{% form 'discount_code_remove' %}
<input type="hidden" name="code" value="{{ code }}">
<span>{{ code }}</span>
<button type="submit">{{ 'cart.remove_code' | t: code: code }}</button>
{% endform %}
{%- endfor -%}
{%- unless cart.discount_stacking.at_capacity -%}
{% form 'discount_code_apply' %}
<label for="discount-code">{{ 'cart.discount_code' | t }}</label>
<input id="discount-code" name="code" required autocomplete="off" autocapitalize="characters">
<button type="submit">{{ 'cart.apply' | t }}</button>
{% endform %}
{%- endunless -%}
Field |
What it holds |
|---|---|
|
The codes the shopper entered and the cart keeps, as strings, upper case. A code is only kept when it applied at the time it was entered. |
|
|
To mark a kept code as inactive, match it against inactive_coupons, case-insensitively:
{%- assign note = nil -%}
{%- assign key = code | upcase -%}
{%- for ic in cart.inactive_coupons -%}
{%- assign ic_key = ic.code | upcase -%}
{%- if ic_key == key -%}{%- assign note = ic.reason -%}{%- endif -%}
{%- endfor -%}
{%- if note != blank -%}<p class="code-note">{{ note }}</p>{%- endif -%}
When a code is refused#
A code that does not apply is not stored and the cart does not change. With JavaScript, the response has success: false and one user_errors entry with code, message and field (["code"]), and no cart. Without JavaScript, the form redirects back (HTTP 303) and the next page has the refusal in ctx.form_notice. The JSON response is HTTP 200 for every refusal, so check success, not the status.
Code |
When |
|---|---|
|
The code was empty. |
|
No active code by that name in this store. |
|
The cart already holds the most codes allowed. |
|
Nothing in the cart qualifies, or the shopper is outside every market. |
|
The minimum spend or quantity is not reached. |
|
Not for this customer, or the shopper must sign in. |
|
The code's usage limit is reached. |
|
It does not combine with the discounts already on the cart. |
|
Too many codes that did not apply: 10 per visitor session or 30 per IP address in 15 minutes. Codes that apply are not counted. |
Print the platform's message; do not write your own text per code. The message is in the shopper's language, and refusal codes added later need no theme update. If you handle tf:action-error yourself, show user_errors[0].message; otherwise the bridge shows it for you. Without JavaScript, print form_notice.message where the shopper will see it.
document.addEventListener("tf:action-error", function (event) {
var d = event.detail || {}, errs = d.result && d.result.user_errors;
if (!/^discount_code_/.test(d.action || "") || !errs || !errs.length) return;
event.preventDefault();
showCodeError(d.form, errs[0].message);
});
Available discounts#
cart.advertised_coupons lists codes the shopper can still use, so the theme can offer them before the shopper types anything. Only codes the merchant marked Show in cart and checkout are listed, and codes already on the cart are left out. Some are listed before they apply: eligible is false and the entry says what is missing.
Field |
What it holds |
|---|---|
|
The code to apply, and the discount's name. |
|
|
|
Whether the code would apply to the cart now; when not, the platform's sentence why. |
|
How much more the shopper must spend for it to apply, when that is what is missing. |
|
Whether it would save money on this cart, and how much. |
|
The minimum order and the end of the offer, when set. |
|
For a bundle: |
Show them in a "View available discounts" disclosure under the code box, each with an Apply button that posts discount_code_apply with the code, disabled while eligible is false. Not every merchant wants the list: give the block that holds the code box a checkbox setting to hide it, on by default (show_available_discounts in the example below).
{%- if block.settings.show_available_discounts and cart.advertised_coupons.size > 0 -%}
<details class="offers">
<summary>{{ 'cart.view_available_discounts' | t }}</summary>
{%- for c in cart.advertised_coupons -%}
<div class="offer">
<p><strong>{{ c.code }}</strong>
{%- case c.type -%}
{%- when 'percentage' -%} {{ c.value }}%
{%- when 'fixed_amount' -%} {{ c.formatted_amount }}
{%- when 'bundle' -%}
{%- if c.bundle_value_mode == 'percentage' %} {{ c.bundle_percentage }}%{% else %} {{ c.formatted_bundle_set_price }}{% endif -%}
{%- endcase -%}</p>
{%- if c.formatted_threshold_shortfall -%}
<p>{{ 'cart.offer_add_more' | t: amount: c.formatted_threshold_shortfall }}</p>
{%- elsif c.eligible != true and c.reason != blank -%}
<p>{{ c.reason }}</p>
{%- elsif c.has_potential_savings -%}
<p>{{ 'cart.offer_save' | t }} {{ c.formatted_potential_amount }}</p>
{%- endif -%}
{% form 'discount_code_apply' %}
<input type="hidden" name="code" value="{{ c.code }}">
<button type="submit"{% unless c.eligible %} disabled{% endunless %}>{{ 'cart.apply' | t }}</button>
{% endform %}
</div>
{%- endfor -%}
</details>
{%- endif -%}
Nudges#
cart.discount_nudges lists automatic discounts the cart is close to: [{id, name, type, reason}], where reason is the platform's sentence saying what to add. For a bundle, bundle adds what completes the set: saving (presented money or null) and missing_items, each with product_id, variant_id, title, url, media, quantity_needed and unit_price. Show a nudge in the cart summary and, for a bundle, an Add button per missing item that posts cart_add.
{%- for n in cart.discount_nudges -%}
<div class="nudge">
<p><strong>{{ n.name }}</strong> {{ n.reason }}
{%- if n.bundle.saving -%}
{%- assign save = n.bundle.saving | money %} {{ 'cart.bundle_save' | t: amount: save }}
{%- endif -%}</p>
{%- for m in n.bundle.missing_items -%}
{% form 'cart_add' %}
<a href="{{ m.url }}">{{ m.title }}</a> {{ m.unit_price | money }}
<input type="hidden" name="product_id" value="{{ m.product_id }}">
{%- if m.variant_id -%}<input type="hidden" name="variant_id" value="{{ m.variant_id }}">{%- endif -%}
<input type="hidden" name="quantity" value="{{ m.quantity_needed | default: 1 }}">
<button type="submit">{{ 'cart.add' | t }}</button>
{% endform %}
{%- endfor -%}
</div>
{%- endfor -%}
Stacking#
cart.discount_stacking reports the market's combination rules for this cart, so the theme can hide what cannot be used.
Field |
What it holds |
|---|---|
|
|
|
The market's codes-per-order limit when stacking. |
|
The platform's limit, which caps |
|
Whether codes apply together with automatic discounts. |
|
True when no more codes can apply to this cart, with the platform's sentence why. |
Read at_capacity rather than comparing counts yourself: it already accounts for the mode, both limits and automatic discounts. When it is true, hide the code box and show capacity_reason:
{%- if cart.discount_stacking.at_capacity and cart.discount_stacking.capacity_reason != blank -%}
<p class="muted">{{ cart.discount_stacking.capacity_reason }}</p>
{%- endif -%}
Free gifts#
A free gift discount either adds its gift automatically or offers it for the shopper to claim. Offered gifts are in cart.gift_offers, and a gift line in the cart has is_free_gift. Show both on the cart page and in the cart drawer, or shoppers never see the gift they unlocked. Fields and a complete example: Free gift offers in the cart.
Showing discounts#
On each line, line_discount is the saving the platform gave that line (null when none) and free_units the units it made free (Buy X Get Y). A line on sale has on_sale with unit_price and regular_unit_price. In the summary, cart.discounts lists each discount with name, code and formatted_adjustment, the effect on the total with its sign already in place; print it as it is. Skip entries whose eligible is false. cart.discount_total and formatted_discount_total give the total saving, when there is one.
{%- assign saved = line.line_discount.amount | default: 0 | plus: 0 -%}
{%- if saved > 0 -%}<p>{{ 'cart.line_saving' | t }} {{ line.line_discount | money }}</p>{%- endif -%}
<dl>
<dt>{{ 'cart.subtotal' | t }}</dt><dd>{{ cart.subtotal | money }}</dd>
{%- for d in cart.discounts -%}
{%- if d.eligible != false and d.formatted_adjustment != blank -%}
<dt>{{ d.name }}{% if d.code != blank %} ({{ d.code }}){% endif %}</dt><dd>{{ d.formatted_adjustment }}</dd>
{%- endif -%}
{%- endfor -%}
<dt>{{ 'cart.total' | t }}</dt><dd>{{ cart.total | money }}</dd>
</dl>
On order pages, read the discounts the order was placed with from customer.order.pricing.discounts: name, code (null for an automatic discount) and amount (presented money), with discount_total for the sum. They never change after the order, even if the discount does.
{%- for d in customer.order.pricing.discounts -%}
<dt>{{ d.name | default: d.code }}</dt><dd>−{{ d.amount | money }}</dd>
{%- endfor -%}
Product pages#
A product's price (and each variant's) tells the theme about a sale: on_sale, current and regular to print, sale and sale_amount (the sale price, null when no sale is running), and sale_starts_at and sale_ends_at, the sale's window when the merchant scheduled one.
{%- assign price = product.display_variant.price | default: product.price -%}
{%- if price.on_sale -%}
<span class="price-sale">{{ price.current | money }}</span>
<s>{{ price.regular | money }}</s>
{%- if price.sale_ends_at -%}
<span data-sale-ends="{{ price.sale_ends_at }}"></span>
{%- endif -%}
{%- else -%}
<span>{{ price.current | money }}</span>
{%- endif -%}
Pages are cached at the edge. The platform retires a cached page when a sale starts or ends, so on_sale and the prices are right on every render; but anything computed from the time of the render, such as "ends in 3 hours", is stale for the next shopper. Print the timestamp into the page and count down in the browser: put the end time in a data- attribute, read it in a script with Date.parse and tick every second. Format a fixed date with the date filter.
product.price_tiers (and each variant's) is the quantity pricing ladder: price tiers with min_quantity and price, plus amount and percent. Show it as a table under the price; the cart applies the right tier on its own. Read the selected variant's tiers first, as for the price:
{%- assign tiers = product.display_variant.price_tiers | default: product.price_tiers -%}
{%- if tiers.size > 0 -%}
<table>
<thead><tr><th>{{ 'products.quantity' | t }}</th><th>{{ 'products.price_each' | t }}</th></tr></thead>
<tbody>
{%- for tier in tiers -%}<tr><td>{{ tier.min_quantity }}+</td><td>{{ tier.price | money }}</td></tr>{%- endfor -%}
</tbody>
</table>
{%- endif -%}
Available offers#
product.offers lists the offers this product qualifies for in the visitor's market: automatic discounts, discount codes and online payment discounts (a saving when the shopper pays with a given payment method, such as a card, Apple Pay, PayPal or SEPA Direct Debit) that the merchant chose to show on product pages. It is set only on the product page's product, not on product cards, wishlist rows or cart lines. Declaring product in ctx_needs is enough: the list is read only when a template uses it. Each entry is a product offer, and every field is always present.
Field |
What it holds |
|---|---|
|
The discount's or online payment discount's id. Stable: use it as a key or element id. |
|
|
|
What the offer gives: |
|
The headline: the merchant's product page label when they set one, otherwise the same text as |
|
What the shopper saves, for example "15% off", "€15.00 off", "Up to 20% off", "Buy 2, get 1 free", "Bundle for $49.00" or "Free gift". |
|
The conditions joined by " · ", for example "On orders of £50.00 or more · On your first order" or "When you pay with PayPal". Empty when there are none. |
|
The code to enter at checkout when |
|
True for |
|
How the shopper gets the offer: "Applied automatically" (automatic), "Applied automatically at checkout" (payment) or "Use this code at checkout" (code). |
|
When the offer ends, RFC 3339 in the store's timezone with its offset (for example |
The list is in display order: automatic discounts, then codes (each by the merchant's discount priority, then newest first), then online payment discounts in the order checkout lists their payment methods. It holds at most 10 offers and is empty when none qualifies. Render it in the order given; never sort, filter or add to it. The platform decides what qualifies: discounts for everyone only (never ones limited to some customers or customer groups), inside their schedule, under their usage limit, aimed at this product (the whole order, the product or one of its variants, one of its collections or categories, a Buy X Get Y item or a bundle item), with every amount they need available in the page's currency, and, for a discount that excludes sale items, only while the product is not on sale. Gift cards never list offers. An online payment discount is listed only while its payment method is offered at checkout in the visitor's market and currency.
Offers are never stale on a cached page: when the merchant turns an offer off, edits or deletes it, or a discount reaches its usage limit, the cached product page is replaced on the next request, and it turns over by itself when an offer starts or ends. Print ends_at as a fixed date with the date filter, or count down in the browser from the timestamp as for sales above. The theme preview and demo stores give an empty list unless their catalogue has real discounts; never fake offers in a preview.
The theme owns only its own strings: the heading (for example "Available offers"), the copy button's label and its copied state, and any "Ends" wording, under its locale keys. Canvas's Available offers block is the reference: a horizontally scrolling row of cards with the title, the conditions below it, then the code with a copy button, or a badge showing apply_text when no code is needed. Copy the behaviour, not the markup:
{%- if product.offers.size > 0 -%}
<section class="offers" aria-labelledby="offers-{{ section.id }}">
<h2 id="offers-{{ section.id }}">{{ 'products.available_offers' | t }}</h2>
<ul class="offers__row">
{%- for offer in product.offers -%}
<li class="offer offer--{{ offer.kind }}" id="offer-{{ offer.id }}">
<p class="offer__title">{{ offer.title }}</p>
{%- if offer.condition_text != blank -%}<p class="offer__conditions">{{ offer.condition_text }}</p>{%- endif -%}
{%- if offer.code -%}
<p class="offer__code">
<bdi dir="ltr" data-offer-code>{{ offer.code }}</bdi>
<button type="button" data-offer-copy data-label-done="{{ 'products.offer_copied' | t }}" hidden>{{ 'products.offer_copy' | t }}</button>
</p>
<p class="offer__apply">{{ offer.apply_text }}</p>
{%- else -%}
<span class="badge">{{ offer.apply_text }}</span>
{%- endif -%}
{%- if offer.ends_at -%}
{%- assign ends = offer.ends_at | date: 'medium' -%}
<p class="offer__ends"><time datetime="{{ offer.ends_at }}">{{ 'products.offer_ends' | t: date: ends }}</time></p>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</section>
<style>
.offers__row { display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; list-style: none; padding: 0; }
.offer { flex: 0 0 min(80%, 280px); scroll-snap-align: start; }
</style>
<script>
(function () {
document.querySelectorAll("[data-offer-copy]").forEach(function (b) {
if (!navigator.clipboard) return;
var code = b.parentNode.querySelector("[data-offer-code]"); if (!code) return;
var label = b.textContent; b.hidden = false;
b.addEventListener("click", function () {
navigator.clipboard.writeText(code.textContent.trim()).then(function () {
b.textContent = b.getAttribute("data-label-done");
setTimeout(function () { b.textContent = label; }, 2000);
});
});
});
})();
</script>
{%- endif -%}
Theme checklist#
Check |
Where |
|---|---|
Code box with apply and remove forms; hidden when |
Cart page and cart drawer |
Refusals show the platform's |
Cart page and cart drawer |
|
Cart page and cart drawer |
Available discounts listed, with a setting to hide them. |
Cart page and cart drawer |
Nudges and bundle missing items with Add buttons; free gift offers. |
Cart page and cart drawer |
Line savings, the discount rows and the total come from the platform; nothing is computed. |
Cart, drawer and order pages |
Sale price with the regular price struck through; countdowns from timestamps in the browser; quantity price table. |
Product page and product cards |
Available offers from |
Product page |
Related: Cart actions for the forms, Response messages for how refusals reach the shopper, and Cart, wishlist, and reviews for every cart field.