Discounts in themes

Discount codes and their refusals, available discounts, nudges, stacking, savings in the cart and on orders, sales, quantity pricing and available offers on product pages.

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.

vascula
{%- 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

cart.coupon_codes

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.

cart.inactive_coupons

[{code, reason}]: kept codes that no longer apply to the cart as it is now, for example after an item was removed. reason is the platform's sentence in the shopper's language. Show it under the code; the shopper can fix the cart or remove the code.

To mark a kept code as inactive, match it against inactive_coupons, case-insensitively:

vascula
{%- 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

CART_COUPON_CODE_REQUIRED

The code was empty.

CART_COUPON_CODE_INVALID

No active code by that name in this store.

CART_COUPON_LIMIT_REACHED

The cart already holds the most codes allowed.

CART_COUPON_NOT_APPLICABLE

Nothing in the cart qualifies, or the shopper is outside every market.

CART_COUPON_REQUIREMENTS_NOT_MET

The minimum spend or quantity is not reached.

CART_COUPON_CUSTOMER_INELIGIBLE

Not for this customer, or the shopper must sign in.

CART_COUPON_UNAVAILABLE

The code's usage limit is reached.

CART_COUPON_CANNOT_COMBINE

It does not combine with the discounts already on the cart.

CART_COUPON_ATTEMPTS_LIMITED

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.

javascript
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

code, name

The code to apply, and the discount's name.

type, value, formatted_amount

percentage (value is the percent), fixed_amount (formatted_amount), free_shipping, buy_x_get_y (bxgy_buy_quantity, bxgy_get_quantity) or bundle.

eligible, reason, reason_kind

Whether the code would apply to the cart now; when not, the platform's sentence why.

formatted_threshold_shortfall

How much more the shopper must spend for it to apply, when that is what is missing.

has_potential_savings, formatted_potential_amount

Whether it would save money on this cart, and how much.

formatted_min_subtotal, ends_at

The minimum order and the end of the offer, when set.

bundle_value_mode, bundle_percentage, formatted_bundle_set_price

For a bundle: percentage (the set is bundle_percentage percent off) or fixed_price (the set costs formatted_bundle_set_price; bundle_set_price is the amount).

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).

vascula
{%- 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.

vascula
{%- 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

mode

single (one discount per order) or stack.

coupon_limit

The market's codes-per-order limit when stacking.

max_coupons_per_order

The platform's limit, which caps coupon_limit.

auto_coupon_combine

Whether codes apply together with automatic discounts.

at_capacity, capacity_reason

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:

vascula
{%- 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.

vascula
{%- 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.

vascula
{%- 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.

vascula
{%- 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:

vascula
{%- 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

id

The discount's or online payment discount's id. Stable: use it as a key or element id.

kind

automatic (an automatic discount), code (a discount code the merchant shows) or payment (an online payment discount, given when the shopper pays with that payment method).

type

What the offer gives: percentage, fixed_amount, tiered, buy_x_get_y, bundle or free_gift. payment offers are percentage or fixed_amount. Use it only to pick an icon or style.

title

The headline: the merchant's product page label when they set one, otherwise the same text as savings_text.

savings_text

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".

condition_text

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.

code

The code to enter at checkout when kind is code; null otherwise. Offer a copy button.

auto_applied

True for automatic and payment offers: no code is needed.

apply_text

How the shopper gets the offer: "Applied automatically" (automatic), "Applied automatically at checkout" (payment) or "Use this code at checkout" (code).

ends_at

When the offer ends, RFC 3339 in the store's timezone with its offset (for example 2026-12-31T23:59:00+01:00); null when it has no end. Format it with the date filter.

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:

vascula
{%- 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 discount_stacking.at_capacity, with capacity_reason shown.

Cart page and cart drawer

Refusals show the platform's user_errors message (or form_notice without JavaScript), never your own text.

Cart page and cart drawer

inactive_coupons reasons shown under their codes.

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.offers, printed as given and in the given order, codes with a copy button, nothing shown when the list is empty.

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.