> ## Documentation Index
> Fetch the complete documentation index at: https://developers.appstle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Combo bundle with a custom storefront

> Sell fixed sets of products at a bundle discount, as one-time or subscription purchases, from your own storefront UI. Read the combos from the rule, add every product of the set as its own cart line with Appstle's bundle attributes, and let the automatic discount price the set.

A **combo bundle** sells a fixed set of products together at a discount. The merchant picks the products whose pages show the offer and defines one or more **combos** for them, such as *"Coffee duo: this coffee plus the decaf, 20% off both"*. Each combo is a set of products, and each product in the set has its own discount.

This guide shows how to run a combo bundle from your own storefront UI: your product page and your cart drawer, without the Appstle widget. You use the same rule data, cart-line attributes and Shopify Function as the built-in widget, so only the UI is yours. If you haven't read the [headless overview](https://developers.appstle.com/bundles/headless-overview.md), start there: it explains the automatic-discount model these steps rely on.

| | |
| - | - |
| **`bundleType`** | `COMBO_BUNDLE` |
| **`bundleSubType`** | Not set (`null`) |
| **Read from** | `appstle_bundles.bundle_rules` shop metafield |
| **Discount applied by** | Appstle automatic discount (Shopify Function) |
| **Purchase type** | One-time, subscription or both, set by the rule's `appliesOn` |

## How pricing works

Unlike a fixed pricing bundle, a combo has no single bundle price. Every product in the set (a **slot**) carries its own `discountType` and `discount`:

| `discountType` | `discount` means |
| - | - |
| `PERCENTAGE` | The percentage taken off the slot's price. `20` is 20% off. |
| `FIXED_AMOUNT` | The amount taken off the slot once per set, whatever the slot's `quantity`, and never more than the slot costs. |

The discount applies automatically, in the cart and at checkout, when the cart holds every product of the set with the combo's attributes on each line. You don't compute or submit it.

> [!NOTE]
>
> A combo can also give a free gift (`freeProducts` on the combo). This guide doesn't cover gifts.

## Step 1 — Read the rule from the metafield

Read the active rules from the **`appstle_bundles.bundle_rules`** shop metafield, as the other bundle guides do (see [Reading bundle configuration](https://developers.appstle.com/bundles/headless-overview.md#reading-bundle-configuration)), and keep the combo rules whose product list contains the product on the page. This guide covers combos shown on the products listed in the rule's `variants`. A combo that takes its products from collections (`productSelectionType` is `COLLECTION`) isn't covered.

```liquid theme={null}
{% assign bundle_rules = shop.metafields.appstle_bundles.bundle_rules.value %}
<script>window.MY_BUNDLE_RULES = {{ bundle_rules | json }};</script>
```

```javascript theme={null}
const rules = window.MY_BUNDLE_RULES || [];

// productId: the numeric product ID, such as {{ product.id }} in Liquid
const comboRulesForProduct = (productId) =>
  rules
    .filter((r) => r.bundleType === 'COMBO_BUNDLE' && r.status === 'ACTIVE')
    .filter((r) => (JSON.parse(r.variants || 'null') || []).some((v) => v.productId === productId));
```

`combos` and `variants` are JSON **strings** inside the rule, so parse them before you use them. The fields you need:

| Field | Use |
| - | - |
| `uniqueRef` | Attach to every cart line as `_appstle-bb-id`. |
| `name` | Display label; attach as `__appstle-bb-name`. |
| `appliesOn` | `ONE_TIME`, `SUBSCRIPTION` or `BOTH`. See [Step 4](#step-4-—-add-subscription-lines). |
| `variants` | JSON array with one item per variant that shows the combos (`productId`, `variantId`, `status` and display fields). The product on the page must be in this list, and the variant you add for the `"default"` slot must be one of its `variantId` values. |
| `combos` | JSON array of the rule's combos (next table). |
| `settings` | JSON string of the rule's options. `enableComboProductChoice` switches on `productChoiceCount`. |

Each item of `combos`:

| Field | Use |
| - | - |
| `id` | The combo's number. It is unique only inside the rule (every rule's combos start at `1`). Attach it to every cart line as `_appstle_bundle_combo_id`. |
| `titleLabel`, `subtitleLabel`, `badgeLabel` | Labels the merchant wrote for the combo. `subtitleLabel` can contain the placeholder `{{saved_total}}`. |
| `products` | The slots of the set (next table). |
| `freeProducts` | Free gift products. Not covered here. |
| `minRequiredProducts` | When set below the number of slots, the combo still discounts if the product on the page and enough other slots to reach this count are in the cart. Only the slots in the cart are discounted. |
| `productChoiceCount` | Applies only when the rule's `settings` has `enableComboProductChoice: true`. Otherwise, ignore it and add every slot. When it applies, the shopper picks exactly this many of the other slots, and the product on the page is always added on top. The Function discounts the product on the page plus the first `productChoiceCount` other slots it finds in the cart, in the order of `products`, and charges any further slot in full. |

Each item of a combo's `products`:

| Field | Use |
| - | - |
| `productId` | The Shopify product ID, or the string `"default"` for the product the shopper is viewing. |
| `variantId`, `variants` | The variant the merchant selected, and the variant IDs the slot accepts (an empty list accepts any variant of the product). In the `"default"` slot they are placeholders (`"default"` and `[]`): add the variant selected on the product page, which must be listed in the rule's `variants`. |
| `quantity` | Units of this slot in one set. |
| `discountType`, `discount` | The slot's discount (see [How pricing works](#how-pricing-works)). |
| `name`, `productHandle`, `image` | Display data. |

Here is the **Coffee duo** combo of a demo rule, trimmed:

```json theme={null}
{
  "id": 1,
  "titleLabel": "Coffee duo",
  "subtitleLabel": "Save {{saved_total}}!",
  "products": [
    { "productId": "default", "variantId": "default", "quantity": 1,
      "discountType": "PERCENTAGE", "discount": 20, "variants": [] },
    { "productId": 8000000000002, "variantId": 44000000000002, "name": "Decaf Coffee",
      "productHandle": "decaf-coffee", "quantity": 1,
      "discountType": "PERCENTAGE", "discount": 20, "variants": [44000000000002] }
  ],
  "freeProducts": []
}
```

> [!TIP]
>
> Hydrate titles, images and prices from **Shopify's own** Storefront API or Liquid product objects, as the other guides do. The `selectedVariants[].price` in the rule is the price when the merchant saved it, not a live price.

## Step 2 — Render your selector

Build the UI that fits your theme, and drive it from the rule:

* Show each combo of the rule for the product on the page. When a rule offers several combos, let the shopper pick one (the widget shows them as options).
* Show the products of the set: the product on the page (the `"default"` slot) and the other slots.
* Show the discount from each slot's `discountType` and `discount`, and optionally the savings (see [Previewing savings](#previewing-savings)).

## Step 3 — Add the set to the cart

Add **every product of the set as its own cart line**, each carrying the same four attributes. On a Shopify theme they are line-item `properties`; through Shopify's Storefront API they are line `attributes`.

| Attribute key | Required | Value |
| - | - | - |
| `_appstle-bb-id` | Yes | The rule's `uniqueRef`. |
| `_appstle_bundles_type` | Yes | `COMBO_BUNDLE`. |
| `_appstle_bundle_combo_id` | Yes | The combo's `id`, as a string such as `"1"`. |
| `__appstle-bb-name` | Recommended | The rule's `name`, a label for the set in your cart UI. The discount doesn't read it. |

```js Shopify theme (Ajax Cart API) theme={null}
const comboProperties = (rule, combo) => ({
  '_appstle-bb-id': rule.uniqueRef,
  '__appstle-bb-name': rule.name,
  '_appstle_bundle_combo_id': String(combo.id),
  '_appstle_bundles_type': 'COMBO_BUNDLE',
});

// lines: [{ variantId, quantity }] for the product on the page and every other slot
const res = await fetch(`${Shopify.routes.root}cart/add.js`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: lines.map((l) => ({
      id: l.variantId,
      quantity: l.quantity,
      properties: comboProperties(rule, combo),
    })),
  }),
});
if (!res.ok) {
  const { description } = await res.json(); // show this to the shopper, then re-read /cart.js
}
```

```graphql Headless (Shopify Storefront API) theme={null}
mutation AddComboBundle($cartId: ID!) {
  cartLinesAdd(
    cartId: $cartId
    lines: [
      {
        merchandiseId: "gid://shopify/ProductVariant/44000000000001"
        quantity: 1
        attributes: [
          { key: "_appstle-bb-id", value: "coffee-combo-ref" }
          { key: "_appstle_bundles_type", value: "COMBO_BUNDLE" }
          { key: "_appstle_bundle_combo_id", value: "1" }
          { key: "__appstle-bb-name", value: "Frequently bought together" }
        ]
      }
      {
        merchandiseId: "gid://shopify/ProductVariant/44000000000002"
        quantity: 1
        attributes: [
          { key: "_appstle-bb-id", value: "coffee-combo-ref" }
          { key: "_appstle_bundles_type", value: "COMBO_BUNDLE" }
          { key: "_appstle_bundle_combo_id", value: "1" }
          { key: "__appstle-bb-name", value: "Frequently bought together" }
        ]
      }
    ]
  ) {
    cart { id }
    userErrors { field message }
  }
}
```

> [!WARNING]
>
> Every line of the set must carry the **same** `_appstle-bb-id` and `_appstle_bundle_combo_id`. The Function groups lines by rule and combo, and discounts a combo only when the cart holds every one of its slots, or as many as `minRequiredProducts` or `productChoiceCount` allow. Lines without the attributes are treated as normal products.

## Step 4 — Add subscription lines

A combo's `appliesOn` decides how it can be bought:

| `appliesOn` | What the Function requires |
| - | - |
| `ONE_TIME` | No line of the combo is on a selling plan. |
| `SUBSCRIPTION` | **Every** line of the combo is on a selling plan. |
| `BOTH` | Any mix. Each line is priced as the shopper bought it. |

To sell a combo as a subscription, add each line with a `selling_plan` (`sellingPlanId` on the line in Shopify's Storefront API). A selling plan belongs to the products it's attached to, so choose each product's own plan with the delivery interval the shopper picked: take it from the product's `selling_plan_allocations`, or from `selling_plan_groups` in `/products/<handle>.js`.

If a line's plan isn't attached to its variant, `/cart/add.js` returns `422`:

```json theme={null}
{ "status": 422, "message": "Cart Error", "description": "Cannot apply selling plan to variant" }
```

A failed request can still leave part of the set in the cart: in a live test with two lines, one of them was added anyway. After a `422`, read `/cart.js` and remove the set's lines before you retry.

```js theme={null}
items: lines.map((l) => ({
  id: l.variantId,
  quantity: l.quantity,
  selling_plan: l.sellingPlanId, // omit for a one-time line
  properties: comboProperties(rule, combo),
}))
```

The combo discount applies on top of the plan's own subscription price. For example, a 22.00 coffee on a plan that takes 10% off costs 19.80, and the combo's 20% takes it to 15.84.

## Step 5 — Discount applies automatically

Once the lines are in the cart, the Function discounts every line of the set. There is nothing else to call. Confirm it on the cart: each line carries a discount allocation titled with the rule's `discountName`, or with its `name` when `discountName` is empty.

This is the real `/cart.js` result for the **Coffee duo** combo (a 22.00 coffee and an 18.00 decaf, 20% off each), trimmed to the decaf line:

```json theme={null}
{
  "original_total_price": 4000,
  "total_discount": 800,
  "total_price": 3200,
  "items": [
    {
      "product_title": "Decaf Coffee",
      "quantity": 1,
      "properties": {
        "_appstle-bb-id": "coffee-combo-ref",
        "__appstle-bb-name": "Frequently bought together",
        "_appstle_bundle_combo_id": "1",
        "_appstle_bundles_type": "COMBO_BUNDLE"
      },
      "original_line_price": 1800,
      "final_line_price": 1440,
      "line_level_discount_allocations": [
        {
          "amount": 360,
          "discount_application": {
            "title": "Frequently bought together",
            "value": "20.0",
            "value_type": "percentage",
            "target_type": "line_item"
          }
        }
      ]
    }
  ]
}
```

When a percentage doesn't come to whole cents, the Function sends that slot's discount as a fixed amount, so `value_type` can be `fixed_amount` on a `PERCENTAGE` slot. Match allocations by `title`, not by type.

Add a set more than once, or add several units of each product, and every complete set is discounted: two units of each product in the same combo cost 64.00 instead of 80.00.

## What does not get a discount

Each of these carts was tested live. None of them gets the combo discount:

| Cart | Why |
| - | - |
| Lines without `_appstle_bundle_combo_id` | The Function can't tell which combo the lines belong to. |
| Lines without `_appstle_bundles_type` set to `COMBO_BUNDLE` | The Function skips the lines. |
| Lines with a `_appstle-bb-id` that matches no rule | There is no rule to apply. |
| Only one product of a two-product combo | The set is incomplete. |
| Products from combo 1 sent with `_appstle_bundle_combo_id` `2` | Combo 2 doesn't list those products. |

> [!NOTE]
>
> A set also stays undiscounted when:
>
> * The rule isn't `ACTIVE`.
> * A line carrying the same `_appstle-bb-id` and `_appstle_bundle_combo_id` doesn't match `appliesOn`. The check covers every set of that combo in the cart, so one one-time set of a `SUBSCRIPTION` combo removes the discount from all of them.
> * The rule is limited by customer tags (`allowedTags`, `restrictTags`) or by uses per customer (`limitToUsePerCustomer`), and the cart's buyer isn't identified or doesn't qualify.
> * The buyer is purchasing for a Shopify B2B company and the rule's `settings` has `excludeB2BCustomers: true`.

## Previewing savings

To show the shopper what they save on one set before checkout, compute it from the slots you add. Cart prices are in cents:

```js theme={null}
// slots: the slots you add to the cart (every slot, or the product on the page plus the shopper's picks)
// priceCents(slot): the live unit price from Shopify, in cents (the selling plan price for a subscription line)
const savingsCents = slots.reduce((sum, slot) => {
  const slotCents = priceCents(slot) * slot.quantity;
  const off =
    slot.discountType === 'PERCENTAGE' ? Math.round((slotCents * slot.discount) / 100)
    : slot.discountType === 'FIXED_AMOUNT' ? Math.round(slot.discount * 100)
    : 0;
  return sum + Math.min(off, slotCents);
}, 0);
```

> [!NOTE]
>
> This is a display-only preview. The Shopify Function applies the authoritative discount at checkout.

## Checklist

**1. The offer is live in the Appstle admin**

Only live offers (`status` is `ACTIVE`) have an automatic discount.

**2. The product on the page is in the rule's variants**

Offer the combos only on products the rule lists.

**3. Every product of the set is in the cart**

One line per slot, with the slot's `quantity`.

**4. Every line carries the three required attributes**

The same `_appstle-bb-id` (the rule's `uniqueRef`), `_appstle_bundles_type` (`COMBO_BUNDLE`) and `_appstle_bundle_combo_id` (the combo's `id`).

**5. Subscription lines match appliesOn**

Put every line on a selling plan for a `SUBSCRIPTION` rule, and none for `ONE_TIME`.

**6. Verify the discount in the cart**

Check `/cart.js` for the discount allocation on each line.

## Next steps

> [card] [Fixed pricing bundle](https://developers.appstle.com/bundles/fixed-pricing-bundle.md)
>
> Sell a curated set for one fixed price.

> [card] [Build your own frontend](https://developers.appstle.com/bundles/headless-overview.md)
>
> How the metafield, attributes and discount Function fit together.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.