Gift wrapping and gift messages in the cart

Let shoppers choose optional fees such as gift wrapping and leave a note for the store in the cart.

A store can offer optional checkout fees, such as gift wrapping or express handling, and customers can leave a note for the store, such as a gift message. Your theme can offer both in the cart drawer and on the cart page. cart.optional_fees lists the fees the shopper can choose, cart.note holds the note, and two hosted actions save the shopper's choices. Read them from any section that declares cart in its ctx_needs.

Checkout picks up both. It starts with the shopper's fees already selected and carries the note into the order, where the merchant sees it as Note from the customer in Store Admin and the mobile app. Checkout shows the note in its order summary with a link back to the cart, which is the only place the shopper edits it.

Fields#

Field

Type

Meaning

cart.optional_fees

Array

The enabled optional fees of the cart's current market, in the merchant's order. Empty when the market has none.

fee.id

String

Send it as fee_id.

fee.name

String

The fee's name, such as Gift wrapping.

fee.description

String

The merchant's description for customers. May be empty.

fee.fee_type

String

fixed or percent.

fee.amount

Price

Fixed fees only: the amount in the cart currency, before tax.

fee.percent

Decimal string

Percent fees only, for example 2.5. Checkout applies it to the merchandise subtotal after discounts.

fee.apply_per

String

order or item. A per-item fee is charged for every unit in the cart.

fee.selected

Boolean

Whether the shopper has chosen the fee.

cart.note

String

The shopper's note, or an empty string.

cart.note_max_length

Integer

The longest note the store accepts, in characters. Use it as the text area's maxlength.

The cart shows a fee before tax and cart.total does not include it. Checkout prices the fee, adds its tax and charges it, so label fees as charged at checkout instead of adding them to a total yourself. The complete field list is in the Context Reference.

The market rule#

A fee belongs to one market. cart.optional_fees lists only the fees of the cart's current market, and each choice is kept together with that market. When the cart's market changes, for example after the shopper picks another country, earlier choices are dropped and the list shows the new market's fees. Checkout confirms each choice again in the shopper's market: if the shipping address moves the shopper to another market, the fee is removed and checkout offers that market's own optional fees. Always render from cart.optional_fees and never keep fee ids in your theme or in browser storage.

Actions#

Action

Path

Fields

Result

cart_fee_select

POST /cart/fees

fee_id, selected (true or false)

Chooses or removes one optional fee. An id that is not an enabled optional fee of the cart's current market is refused with a user error.

cart_note_update

POST /cart/note

note

Saves the note. Leading and trailing spaces are trimmed, an empty note clears it, and a note longer than cart.note_max_length characters is refused.

Both are cart actions and behave like cart_update. Build them with {% form %}, which adds the CSRF token, and they work without JavaScript. A JSON response has success, the updated cart and, when the action is refused, user_errors. Fields, limits and error codes are listed under Hosted Actions: Cart.

A cart drawer example#

This section adds the fee choices and a gift message to a cart drawer. Each fee is a labelled checkbox in its own small form, its description is linked to the checkbox, and the note is a labelled text area with a character count. The drawer is a [data-tf-refresh] section, so after either action the storefront bridge re-renders it with the saved state and keeps it open.

cart-drawer.vasc vascula
<div class="cart-drawer" data-tf-refresh="cart-drawer" data-tf-refresh-origin="true" data-tf-preserve-state="open">
  {%- comment -%} your cart lines and totals {%- endcomment -%}

  {%- if cart.optional_fees.size > 0 -%}
    <fieldset class="cart-extras">
      <legend>{{ 'cart.extras' | t }}</legend>
      {%- for fee in cart.optional_fees -%}
        {% form 'cart_fee_select', class: 'cart-fee' %}
          <input type="hidden" name="fee_id" value="{{ fee.id }}">
          <input type="hidden" name="selected" value="{% if fee.selected %}false{% else %}true{% endif %}">
          <label>
            <input type="checkbox" data-cart-fee-toggle{% if fee.selected %} checked{% endif %}{% if fee.description != blank %} aria-describedby="cart-fee-{{ fee.id }}"{% endif %}>
            {{ fee.name }}
            <span class="cart-fee__price">
              {%- if fee.fee_type == 'fixed' -%}
                {{ fee.amount | money: isolate: true }}{% if fee.apply_per == 'item' %} {{ 'cart.fee_per_item' | t }}{% endif %}
              {%- else -%}
                {{ fee.percent }}%
              {%- endif -%}
            </span>
          </label>
          {%- if fee.description != blank -%}
            <p id="cart-fee-{{ fee.id }}" class="muted">{{ fee.description }}</p>
          {%- endif -%}
          <noscript>
            <button type="submit">{% if fee.selected %}{{ 'cart.fee_remove' | t }}{% else %}{{ 'cart.fee_add' | t }}{% endif %}</button>
          </noscript>
        {% endform %}
      {%- endfor -%}
      <p class="muted">{{ 'cart.fees_at_checkout' | t }}</p>
    </fieldset>
  {%- endif -%}

  {% form 'cart_note_update', class: 'cart-note' %}
    <label for="cart-note">{{ 'cart.note_label' | t }}</label>
    <textarea id="cart-note" name="note" rows="3" maxlength="{{ cart.note_max_length }}" aria-describedby="cart-note-count">{{ cart.note }}</textarea>
    <p id="cart-note-count" class="muted" data-cart-note-count>{{ cart.note.size }} / {{ cart.note_max_length }}</p>
    <button type="submit">{{ 'cart.note_save' | t }}</button>
  {% endform %}
</div>

Without JavaScript, each fee shows an Add or Remove button and the note has its own Save button. With JavaScript, ticking a box submits its form straight away. The script below does not send anything itself: it sets selected and asks the bridge to submit, keeps the count up to date, and puts a refused checkbox back.

cart-extras.js javascript
// The storefront bridge sends hosted-action forms. This script only asks it to.
document.addEventListener('change', function (event) {
  var box = event.target.closest && event.target.closest('[data-cart-fee-toggle]');
  if (!box) return;
  box.form.elements.selected.value = box.checked ? 'true' : 'false';
  box.form.requestSubmit();
});

// Keep the character count in step with the note.
document.addEventListener('input', function (event) {
  if (event.target.id !== 'cart-note') return;
  var count = document.querySelector('[data-cart-note-count]');
  if (count) count.textContent = Array.from(event.target.value).length + ' / ' + event.target.maxLength;
});

// A refused choice: put the checkbox back. The bridge still shows the error message.
document.addEventListener('tf:action-error', function (event) {
  if (event.detail.action !== 'cart_fee_select') return;
  var box = event.detail.form.querySelector('[data-cart-fee-toggle]');
  if (box) box.checked = !box.checked;
});

The text comes from your locale files, so it can be translated like the rest of the theme:

locales/en.json json
{
  "cart": {
    "extras": "Extras",
    "fee_per_item": "per item",
    "fee_add": "Add",
    "fee_remove": "Remove",
    "fees_at_checkout": "Charged at checkout, with tax where it applies.",
    "note_label": "Gift message or note for the store",
    "note_save": "Save note"
  }
}

Checklist#

  • Fees are rendered only from cart.optional_fees; no fee id or name is written into the theme.

  • Fees are labelled as charged at checkout and are not added to a cart total.

  • Every checkbox and the text area have a visible label, and the text area's maxlength comes from cart.note_max_length.

  • Refused actions show their user_errors. The bridge shows them unless you cancel tf:action-error.

  • If the cart page and the drawer can appear on the same page, give each text area and count its own id.

  • The drawer and the cart page both offer the fees and the note, so the shopper finds them wherever they review the cart.