# Custom Cart Modules

> Read cart state, send managed commands, and show conditional content with complete examples and API limits.

Canonical URL: https://3bdc8bdc6dfd:3005/guides/custom-cart-modules

Custom cart modules can show live reward progress, hide an upsell after its
variant is added, and update the cart through managed commands. They use normal
Lexsis section source inside a cart profile.

Read `lexsis_cart.get` and `lexsis_cart.capabilities` before editing. Capabilities
returns `snapshot_schema`, `module_lifecycle`, `commands`, and `conditions`.
Use the current profile version in every `lexsis_drafts.cart_edit` call.

Scripts receive `section`, `lifecycle.query`, `lifecycle.cart`, and
`lifecycle.onCart`. Keep DOM access within the section. `window`, `document`,
direct Shopify mutations, and forged command response events are rejected.
Normal module CSS is wrapped in `@scope ([data-cart-custom-module="<id>"])`.
The wrapper is both `section` and `lifecycle.root`; it surrounds your authored
inner section. Cart scripts must listen on that wrapper for responses.
See [Cart Styling Hooks](/guides/cart-styling) for the full part map and inline
styles. Source-bearing upserts replace the entire module source, including CSS
and script; omit source to preserve it when changing visibility or placement.

## Add a module to a draft

Call `lexsis_drafts` with action `cart_edit`. Replace `PROFILE_UUID` and
`expected_version` with the values returned by `lexsis_cart.get`:

```json
{
  "action": "cart_edit",
  "args": {
    "cart_profile_id": "PROFILE_UUID",
    "expected_version": 12,
    "change_note": "Show remaining spend for the next reward",
    "patch": {
      "composition_ops": [{
        "operation": "upsert",
        "module_id": "reward-card",
        "region": "body",
        "source": "<!-- section: reward-card -->\n<section><p data-lx-if=\"rewards.next.remaining.minor > 0\">Only <strong data-lx-text=\"rewards.next.remaining\"></strong> to go for <span data-lx-text=\"rewards.next.title\"></span>.</p></section>",
        "visible_when": {
          "op": "AND",
          "clauses": [{"field": "cart.item_count", "op": "gt", "value": 0}]
        }
      }]
    }
  }
}
```

New custom modules require one source section. Existing custom or optional
built-in modules may omit `source` when changing visibility. Use `region` and
the supported placement operations to position custom content. Cart lines,
summary, checkout and the drawer remain renderer-owned.

The edit saves a draft. Re-read it and use `lexsis_cart.preview` to verify it;
publication remains a separate approved action through `lexsis_live_ops.cart_publish` or the app.

## Complete upsell with live state

This is a compiler-tested template. Replace the example variant GID and item
label with a verified, available catalog variant before saving. The template
does not invent a price or discount. It displays the profile's next reward,
updates after cart changes, and disables adding when the variant is present.

```html lexsis-cart-example
<!-- section: matching-item -->
<section class="upsell" data-variant-id="gid://shopify/ProductVariant/123">
  <h3>Add a matching item</h3>
  <p data-remaining hidden></p>
  <button type="button" data-lx-control="add-upsell" disabled>Add item</button>
  <p data-result role="status" aria-live="polite"></p>
  <style>
    .upsell { padding: 16px; border: 1px solid var(--lx-border-color); }
    .upsell button { min-height: 44px; padding: 8px 16px; }
    .upsell button:disabled { opacity: 0.6; }
  </style>
  <script>
    const content = lifecycle.query('[data-variant-id]');
    const button = lifecycle.query('[data-lx-control="add-upsell"]');
    const remaining = lifecycle.query('[data-remaining]');
    const result = lifecycle.query('[data-result]');
    const variantId = content.dataset.variantId;
    let requestId = null;
    let sequence = 0;
    let present = false;
    const refreshButton = () => {
      button.disabled = Boolean(requestId) || present;
      button.textContent = requestId ? 'Adding...' : present ? 'Already in cart' : 'Add item';
    };
    lifecycle.onCart((cart) => {
      present = cart.lines.some((line) => line.variantId === variantId);
      const next = cart.rewards.next;
      remaining.hidden = !next;
      remaining.textContent = next
        ? new Intl.NumberFormat(cart.context.locale || undefined, {
            style: 'currency', currency: next.remaining.currencyCode
          }).format(Number(next.remaining.amount)) + ' to go for ' + next.title
        : '';
      refreshButton();
    });
    lifecycle.on(button, 'click', () => {
      if (requestId || present) return;
      requestId = 'upsell-' + Date.now() + '-' + (++sequence);
      refreshButton();
      result.textContent = '';
      section.dispatchEvent(new CustomEvent('lx:cart:add-items', {
        bubbles: true,
        detail: { requestId, items: [{ variantId, quantity: 1 }], openCart: true }
      }));
    });
    lifecycle.on(section, 'lx:cart:add-items:pending', (event) => {
      if (event.detail.requestId === requestId) result.textContent = 'Adding item...';
    });
    lifecycle.on(section, 'lx:cart:add-items:success', (event) => {
      if (event.detail.requestId !== requestId) return;
      requestId = null;
      present = lifecycle.cart.lines.some((line) => line.variantId === variantId);
      result.textContent = 'Added to your cart';
      refreshButton();
    });
    lifecycle.on(section, 'lx:cart:add-items:error', (event) => {
      if (event.detail.requestId !== requestId) return;
      requestId = null;
      result.textContent = event.detail.message;
      refreshButton();
    });
  </script>
</section>
```

To hide this whole upsell once added, include this sibling of `source` in the
upsert, using the same verified variant GID:

```json
{
  "visible_when": {
    "op": "AND",
    "clauses": [{"field": "cart.has_variant_id", "op": "neq", "value": "gid://shopify/ProductVariant/123"}]
  }
}
```

For a trust row on nonempty carts, use `cart.item_count`, `op:"gt"`, `value:0`.
Rules apply to optional built-ins as well. Required cart lines, summary and
checkout cannot be hidden. Hidden modules use `hidden` and leave the
accessibility tree; they remain in the DOM for live visibility changes.
Disabled modules (`set_enabled:false`) are omitted from generated sections.

## Read the cart

`lifecycle.cart` is a deeply frozen snapshot. Its objects, arrays, line attributes,
and Money values cannot be changed. Use commands for cart mutations.

```ts
type Money = Readonly<{
  amount: string;       // Decimal amount, for example "552.00"
  currencyCode: string; // Store currency, for example "INR"
  minor: number;        // Integer minor units, for example 55200
}>;

interface CartSnapshot {
  readonly status: "idle" | "pending" | "error";
  readonly itemCount: number;
  readonly currency: string;
  readonly subtotal: Money;
  readonly compareAtSubtotal: Money;
  readonly savings: Money;
  readonly discountCodes: ReadonlyArray<Readonly<{
    code: string; applicable: boolean;
  }>>;
  readonly lines: ReadonlyArray<Readonly<{
    id: string; variantId: string; productId: string; handle: string;
    title: string; variantTitle: string; quantity: number;
    price: Money; compareAtPrice: Money | null; lineTotal: Money;
    sellingPlanId: string | null;
    attributes: Readonly<Record<string, string>>;
    tags: readonly string[]; collectionIds: readonly string[];
    isRewardGift: boolean;
  }>>;
  readonly rewards: Readonly<{
    next: Readonly<{
      id: string; type: string; title: string;
      threshold: Money; remaining: Money;
    }> | null;
    unlocked: ReadonlyArray<Readonly<{
      id: string; type: string; title: string;
    }>>;
    chosenGiftVariantId: string | null;
  }>;
  readonly context: Readonly<{
    market: string; locale: string; pageHandle: string; pageType: string;
    utm: Readonly<Record<string, string>>;
    customer: Readonly<{ loggedIn: boolean; returning: boolean }>;
  }>;
}
```

`itemCount` sums quantities, not distinct lines. `subtotal` uses confirmed
merchandise after line discounts, before shipping, tax and remaining order
discounts. `compareAtSubtotal` sums compare-at price, or unit price when absent,
times quantity. `savings` adds remaining order discounts to
`compareAtSubtotal - subtotal`; line discounts are not counted twice.
The built-in summary uses the same selectors.

Reward remaining uses the same eligibility calculation as the built-in reward
display. `unlocked` means eligible, not proof that Shopify applied a discount.
`next` is null when no verified locked reward remains.

### Subscribe to settled changes

```js
const unsubscribe = lifecycle.onCart((snapshot, previous) => {
  // Update this section from the new CartSnapshot.
});
```

The initial callback receives `previous === null`. Later callbacks run after
cart mutations and reward settlement, at most once per animation frame.
Changes within the same frame coalesce. Calling `unsubscribe()` stops the
listener; removing the module also cleans it up automatically.

During a mutation, the getter reports `pending` with the last confirmed
merchandise values. Before authoritative currency or prices are available,
the getter throws a readiness error and subscriptions wait. Subscribe during
initialization instead of reading the getter immediately. Currency is never
replaced by a default.

### Example: a live gift-remaining card

Use this section as the module's `source`. It displays the remaining spend only
when the next configured reward is a free gift:

```html
<!-- section: gift-remaining -->
<section>
  <p data-gift-message hidden>
    Only <strong data-remaining></strong> to go for your gift.
  </p>
  <script>
    lifecycle.onCart((cart) => {
      const next = cart.rewards.next;
      const isGift = next && next.type === 'free_gift';
      lifecycle.query('[data-gift-message]').hidden = !isGift;
      lifecycle.query('[data-remaining]').textContent = isGift
        ? new Intl.NumberFormat(cart.context.locale || undefined, {
            style: 'currency',
            currency: next.remaining.currencyCode
          }).format(Number(next.remaining.amount))
        : '';
    });
  </script>
</section>
```

The amount and currency come from the profile's cart state. No gift threshold,
product, or currency is invented in the script.

## Send cart commands

Dispatch a bubbling `CustomEvent` from `section`. Every command requires a
unique `requestId`, 1–128 characters. Mark interactive controls with
`data-lx-control` and bind their handlers through the lifecycle.

| Event | Additional payload and limits |
|---|---|
| `lx:cart:update-line` | `lineId` string, integer `quantity` 0–99; zero removes |
| `lx:cart:remove-line` | `lineId` string |
| `lx:cart:apply-code` | `code` string, 1–255 characters |
| `lx:cart:remove-code` | `code` string, 1–255 characters; matching ignores case |
| `lx:cart:set-attributes` | `attributes` string record, merged into existing cart attributes |
| `lx:cart:set-note` | `note` string, 0–5000 characters; empty string clears |
| `lx:cart:choose-gift` | Configured `rewardId` string and eligible `variantId` |
| `lx:cart:swap-variant` | `lineId`, replacement `variantId`, optional integer `quantity` 1–99 |

Read line IDs from `lifecycle.cart.lines`. They are not variant IDs. Line IDs
are limited to 512 characters and reward IDs to 128. A variant ID must be a
Shopify ProductVariant GID such as `gid://shopify/ProductVariant/123`, at most
255 characters.

Attributes allow up to 20 entries, keys of 1–64 characters, and values of
0–255 characters. Reserved Lexsis attribution/reward keys and prototype keys
are rejected. Other existing attributes are preserved.

Gift selection requires an enabled, unlocked free-gift reward and an available
configured variant. Remove an existing chosen gift before choosing another.
Gift quantities remain controlled by reward rules. Shopper code application
and gift choice do not create or edit promotion configuration.

The existing [`lx:cart:add-items` contract](/guides/cart-v2#custom-cart-interactions-and-batch-add)
supports batch additions and optional drawer opening.

### Example: save an order note

```html
<!-- section: cart-note -->
<section>
  <label>Order note <textarea data-note maxlength="5000"></textarea></label>
  <button type="button" data-lx-control="save-note">Save note</button>
  <span data-result role="status"></span>
  <script>
    let sequence = 0;
    lifecycle.on(lifecycle.query('[data-lx-control="save-note"]'), 'click', () => {
      section.dispatchEvent(new CustomEvent('lx:cart:set-note', {
        bubbles: true,
        detail: {
          requestId: 'note-' + Date.now() + '-' + (++sequence),
          note: lifecycle.query('[data-note]').value
        }
      }));
    });
    lifecycle.on(section, 'lx:cart:set-note:success', () => {
      lifecycle.query('[data-result]').textContent = 'Note saved';
    });
    lifecycle.on(section, 'lx:cart:set-note:error', (event) => {
      lifecycle.query('[data-result]').textContent = event.detail.message;
    });
  </script>
</section>
```

### Responses and retries

Responses arrive on the originating `[data-cart-custom-module]` wrapper and
bubble. An inner-section listener will not receive an event sent from its
parent wrapper. Responses use the original event name plus
one of these suffixes:

| Suffix | Meaning | Detail fields |
|---|---|---|
| `:pending` | Request received | `requestId`, `addedLines` |
| `:accepted` | Mutation queued | `requestId`, `addedLines` |
| `:confirmed` | Primary mutation confirmed | `requestId`, `addedLines`, `cart`, `warnings` |
| `:success` | All cart settlement completed | `requestId`, `addedLines`, `cart`, `warnings` |
| `:error` | Rejected or uncertain | `requestId`, `code`, `message`, `failedItems`, optional `cart` |

For the eight commands above, `addedLines` is an empty compatibility array.
Use `lifecycle.cart` or `onCart` for the complete module snapshot; response
`cart` is the existing command cart summary. Show completion copy on `:success`,
not `:pending` or `:accepted`.

Identical replay of an ID and payload returns the recorded terminal response
without another write. A changed payload or command under the same ID returns
`request_id_conflict`. This protection is scoped to the browser tab and cart
context, keeps up to 500 completed requests, and expires after 24 hours.
It is not a durable cross-device idempotency service.

| Error | What to do |
|---|---|
| `invalid_request`, `invalid_request_id`, `invalid_line_id`, `invalid_variant_id`, `invalid_reward_id`, `invalid_quantity`, `invalid_code`, `invalid_note`, `invalid_attributes`, `reserved_attribute` | Fix the payload using the contract above |
| `line_not_found` | Refresh the line ID from the current snapshot |
| `reward_not_found`, `reward_locked`, `unavailable_variant`, `gift_already_chosen`, `reward_quantity_locked` | Recheck the configured reward and current eligibility |
| `code_not_applicable` | Show that the code could not be confirmed applicable |
| `request_id_conflict` | Use a new ID for an intentional new action; keep the original ID for identical retries |
| `cart_profile_unavailable`, `cart_runtime_unavailable` | Resolve the effective profile/runtime before retrying |
| `shopify_rejected`, `change_adjusted`, `cart_update_failed` | Show the error and inspect the current cart; Shopify may also return a specific rejection code |
| `partial_swap`, `outcome_unknown` | Inspect the confirmed cart and recovery state; do not blindly issue another add |

On a Storefront cart, a variant swap uses one merchandise update. An AJAX cart
adds the replacement before removing the original. If removal fails,
`partial_swap` preserves the accepted replacement and reports the failure.
A lost response returns `outcome_unknown`; recovery uses reads instead of
automatically repeating an uncertain write.

## Show or hide entire modules

`visible_when` accepts `{op: "AND" | "OR", clauses: [{field, op, value}]}` with
1–50 clauses. `AND` requires every clause; `OR` requires at least one.
Pass `visible_when: null` to remove a condition.

### Example: hide an upsell after its variant is added

Place this operation in `patch.composition_ops`, using the actual module ID
and product variant GID:

```json
{
  "operation": "upsert",
  "module_id": "upsell-card",
  "visible_when": {
    "op": "AND",
    "clauses": [{
      "field": "cart.has_variant_id",
      "op": "neq",
      "value": "gid://shopify/ProductVariant/123"
    }]
  }
}
```

For an empty-cart rule, use `cart.item_count`, `gt`, and `0`, as in the first
example. Both rules update without a page reload.

Conditions work on custom modules and optional built-ins, including inline
offers, coupons, subscription controls and payment logos in either placement.
Required cart lines, summary and checkout cannot be hidden.
Hidden content has the `hidden` attribute and leaves the accessibility tree.
Subscriptions remain active so it can reappear. A disabled module stays absent
regardless of its condition.

### Complete condition reference

| Fields | Value | Allowed operators |
|---|---|---|
| `cart.item_count` | Number | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`, `in` |
| `cart.subtotal`, `cart.savings`, `reward.remaining` | Number in minor units | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`, `in` |
| `cart.has_product_id`, `cart.has_variant_id`, `cart.has_collection`, `cart.has_tag`, `cart.has_code`, `reward.unlocked` | ID/string, or list for `in` | `eq`, `neq`, `contains`, `in` |
| `cart.qty_of_variant` | `{variantId, quantity}`; quantity is a number, range or list | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`, `in` |
| `reward.next_id`, `market`, `utm.source`, `utm.campaign`, `page.handle`, `page.type` | String, or list for `in` | `eq`, `neq`, `contains`, `in` |
| `customer.logged_in`, `customer.returning` | Boolean | `eq`, `neq` |
| `time.between` | Two ordered ISO timestamps with time zones | `between` |

For membership fields, `eq` and `contains` mean the supplied ID/string is
present; `neq` means absent; `in` matches any supplied value. Code membership
includes applicable codes and ignores case. For string fields, `contains`
means substring matching.

`between` includes both endpoints and requires two ordered values. `in`
requires 1–50 values. String values are limited to 255 characters. For example,
quantity comparison uses
`{"field":"cart.qty_of_variant","op":"gte","value":{"variantId":"gid://shopify/ProductVariant/123","quantity":2}}`.

Time windows use values such as
`["2026-12-01T00:00:00Z","2026-12-02T00:00:00Z"]` and refresh every second,
even without a cart mutation. Unknown fields, invalid operators and wrong
types return precise validation paths, for example
`visible_when.clauses[0].value`.

## Bind content without scripts

### Example: conditional text and formatted money

```html
<!-- section: reward-message -->
<section>
  <p data-lx-if="cart.item_count &gt; 0 &amp;&amp; rewards.next.remaining.minor &gt; 0">
    Only <strong data-lx-text="rewards.next.remaining"></strong> to go for
    <span data-lx-text="rewards.next.title"></span>.
  </p>
</section>
```

`data-lx-if` supports literals, known paths, parentheses, `!`, `&&`, `||`,
and `==`, `===`, `!=`, `!==`, `<`, `<=`, `>`, `>=`.
`cart.subtotal` and `cart.subtotal.minor` both compare minor units.
Use `.minor` when comparing snapshot Money paths such as `rewards.next.remaining`.

Membership fields also work as predicates:
`!cart.has_variant_id('gid://shopify/ProductVariant/123')` and
`!reward.unlocked('REAL_REWARD_ID')`.
`cart.qty_of_variant('gid://shopify/ProductVariant/123')` returns a quantity.
`time.between('2026-12-01T00:00:00Z','2026-12-02T00:00:00Z')` tests a time window.

The compiler rejects unknown paths/functions, assignment and arbitrary
JavaScript. Expressions allow at most 2048 characters, 256 tokens and 32 nesting
levels.

### Text binding paths

`data-lx-text` formats Money using the snapshot currency and locale. Scalars
become text; null becomes an empty string. Text bindings cannot replace a
commerce island or a container holding one.

| Paths | Bound value |
|---|---|
| `status`, `itemCount`, `currency` | Scalar cart values |
| `subtotal`, `compareAtSubtotal`, `savings` | Formatted Money |
| `rewards.next.id`, `rewards.next.type`, `rewards.next.title`, `rewards.chosenGiftVariantId` | Reward text or null |
| `rewards.next.threshold`, `rewards.next.remaining` | Formatted Money or null |
| Any Money path above plus `.amount`, `.currencyCode`, or `.minor` | Raw scalar value |
| `context.market`, `context.locale`, `context.pageHandle`, `context.pageType` | Context text |
| `context.customer.loggedIn`, `context.customer.returning` | Boolean text |
| `context.utm.source`, `context.utm.campaign` | UTM text |

Use `capabilities.conditions.text_paths` for the current allowlist. Arrays such
as cart lines require a script using `onCart`; they are not text binding paths.

## Data availability and verification

- Customer flags require a trusted host session provider. Without one,
  `loggedIn` and `returning` are false. These display conditions are not access
  control.
- Collection membership requires Shopify Storefront metadata access, including
  when cart writes use AJAX. Membership is paginated and cached for 60 seconds.
  Unavailable metadata becomes an empty array; it is not proof of exclusion.
- Price/currency readiness, gift eligibility and confirmed discount application
  are separate. Avoid showing a gift as applied merely because it is unlocked.
- Preview both empty and populated carts. Add/remove the upsell variant, check
  reward text, and verify command success/error behavior.
- This feature requires the supporting API and cart runtime release. Updating
  source or documentation does not upgrade an older published runtime. Preview
  validation is not proof of a live Shopify mutation.

See [Cart Profile Tools](/tools/cart) for draft edits and
[Cart Profiles](/guides/cart-v2) for profile resolution and publication.
