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 |
|---|---|---|
|
Array |
The enabled optional fees of the cart's current market, in the merchant's order. Empty when the market has none. |
|
String |
Send it as |
|
String |
The fee's name, such as Gift wrapping. |
|
String |
The merchant's description for customers. May be empty. |
|
String |
|
|
Price |
Fixed fees only: the amount in the cart currency, before tax. |
|
Decimal string |
Percent fees only, for example |
|
String |
|
|
Boolean |
Whether the shopper has chosen the fee. |
|
String |
The shopper's note, or an empty string. |
|
Integer |
The longest note the store accepts, in characters. Use it as the text area's |
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 |
|---|---|---|---|
|
|
|
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. |
|
|
|
Saves the note. Leading and trailing spaces are trimmed, an empty note clears it, and a note longer than |
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.
<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.
// 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:
{
"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
maxlengthcomes fromcart.note_max_length.Refused actions show their
user_errors. The bridge shows them unless you canceltf: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.