{"templateId":"markdown","sharedDataIds":{"sidebar":"sidebar-docs/themes/sidebars.yaml"},"props":{"codeGuideFiles":[],"dynamicMarkdocComponents":[],"metadata":{"type":"markdown"},"seo":{"title":"Product Bundles","description":"Fluid is the open e-commerce platform built for direct-sales."},"ast":{"$$mdtype":"Tag","name":"article","attributes":{},"children":[{"$$mdtype":"Tag","name":"Heading","attributes":{"level":1,"id":"product-bundles"},"children":["Product Bundles"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["A ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["product bundle"]}," lets a single product be sold as a configurable kit of other products — a mix of fixed \"included\" items and customer-customizable groups. On the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Product template"]},", a bundle product exposes its full structure through a set of Liquid drops, so a section (for example, a custom bundle builder) can render the groups, items, pricing, and subscription options."]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Bundle variables are only populated for products that ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["are"]}," bundles. For a non-bundle product, ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product.bundle_config"]}," is an empty object (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{}"]},") and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product.product_bundle_groups"]}," is an empty array."]}]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"where-bundles-are-exposed"},"children":["Where bundles are exposed"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["All bundle data hangs off the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product"]}," drop (Product scope). These are the entry points:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{\n  \"product\": {\n    \"bundle_config\": \"Bundle configuration object — empty {} for non-bundle products\",\n    \"mutually_exclusive_groups <Array>\": \"Array of mutually-exclusive group sort_order pairs — groups that cannot be selected together\",\n    \"is_enrollment\": \"Boolean — true when the product's bundle is an enrollment bundle\",\n    \"track_inventory_on_bundle_items\": \"Boolean — whether bundle item inventory is tracked at the item level\",\n    \"bundle_in_stock\": \"Boolean — whether the full bundle is in stock for the storefront warehouse\",\n    \"bundle_available_quantity\": \"Available quantity for the full bundle (null if unlimited)\",\n    \"product_bundles <Array>\": \"Statically bundled products — see ProductBundle below\",\n    \"product_bundle_groups <Array>\": \"Bundle groups and their items — see ProductBundleGroup below\",\n    \"subscription_plans <Array>\": \"Bundle-wide subscription plans — the bundle product's own plans (same shape as an item's subscription_plans). Drives the bundle-wide Subscribe & Save option; empty when the bundle has no plans.\"\n  }\n}\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Guard for bundle data before rendering:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"html","header":{"controls":{"copy":{}}},"source":"{'%' if product and product.product_bundle_groups '%'}\n  {'%' for group in product.product_bundle_groups '%'}\n    <h3>{{ group.title | default: 'Group' }}</h3>\n  {'%' endfor '%'}\n{'%' endif '%'}\n","lang":"html"},"children":[]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"product.product_bundle_groups--productbundlegroup"},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product.product_bundle_groups[]"]}," — ProductBundleGroup"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Each entry is a bundle group: either a fixed set of ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["included"]}," items, or a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["customizable"]}," group the customer picks from. Items live under ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bundle_group_items"]},"."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{\n  \"id\": \"Bundle group ID\",\n  \"title\": \"Bundle group title\",\n  \"description\": \"Bundle group description\",\n  \"group_type\": \"Group type: `included` (fixed items) or `customizable` (customer picks)\",\n  \"sort_order\": \"Sort order of the group on the storefront\",\n  \"selection_type\": \"Completion rule for customizable groups: `exact`, `min_only`, `max_only`, or `min_and_max`\",\n  \"min_selections\": \"Minimum number of selections required (null for `max_only` groups, which have no lower bound)\",\n  \"max_selections\": \"Maximum number of selections allowed (null/unbounded for `min_only`; collapses to `min_selections` for `exact`)\",\n  \"pricing_type\": \"Pricing type: `fixed_price` or `dynamic_price`\",\n  \"fixed_price\": \"Group fixed price as string\",\n  \"min_price\": \"Minimum group price as string (dynamic_price groups)\",\n  \"max_price\": \"Maximum group price as string (dynamic_price groups)\",\n  \"compare_at_price\": \"Compare-at (strikethrough) price as string\",\n  \"group_cv\": \"Group commission value — deprecated, prefer per-country CV via country_pricing\",\n  \"group_qv\": \"Group qualifying value — deprecated, prefer per-country QV via country_pricing\",\n  \"track_quantity\": \"Boolean — whether to track quantity at the group level\",\n  \"country_pricing <Array>\": {\n    \"country_code\": \"ISO country code (e.g., US)\",\n    \"enabled\": \"Boolean — whether this country entry is active\",\n    \"price\": \"Country-specific price as string\",\n    \"subscription_price\": \"Country-specific subscription price as string\",\n    \"compare_price\": \"Country-specific compare-at price as string\",\n    \"cv\": \"Country-specific commission value\",\n    \"qv\": \"Country-specific qualifying value\",\n    \"wholesale\": \"Country-specific wholesale price\",\n    \"points\": \"Country-specific points value\",\n    \"wholesale_cv\": \"Country-specific wholesale commission value\",\n    \"wholesale_qv\": \"Country-specific wholesale qualifying value\"\n  },\n  \"pricing_config\": \"Raw pricing configuration object (source for fixed_price, country_pricing, etc.)\",\n  \"allow_subscriptions\": \"Boolean — whether items in this group can be subscribed\",\n  \"force_subscriptions\": \"Boolean — whether subscription is required for every item in this group\",\n  \"image_urls <Array>\": \"Array of group image URLs\",\n  \"images <Array>\": {\n    \"id\": \"Image ID\",\n    \"src\": \"Image src url\",\n    \"url\": \"Image url\"\n  },\n  \"bundle_group_items <Array>\": {\n    \"id\": \"Bundle group item ID\",\n    \"quantity\": \"Quantity of this item in the bundle\",\n    \"sort_order\": \"Sort order of the item within the group\",\n    \"price\": \"Item price as string\",\n    \"cv\": \"Item commission value\",\n    \"qv\": \"Item qualifying value\",\n    \"is_default\": \"Boolean — whether this item is pre-selected by default\",\n    \"display_quantity\": \"Display quantity for the item\",\n    \"max_quantity\": \"Maximum quantity a customer can add of this item\",\n    \"allow_subscription\": \"Boolean — whether this item allows subscription\",\n    \"force_subscription\": \"Boolean — whether subscription is required for this item\",\n    \"image_url\": \"Item image URL\",\n    \"available_country_codes <Array>\": \"Array of country ISO codes the item is available in\",\n    \"country_prices\": \"Object mapping country ISO code to item price\",\n    \"country_subscription_prices\": \"Object mapping country ISO code to item subscription price\",\n    \"subscription_plan_id\": \"Subscription plan ID for the item (if any)\",\n    \"subscription_plans <Array>\": {\n      \"id\": \"Subscription plan ID\",\n      \"name\": \"Plan name\",\n      \"billing_interval\": \"Billing interval count\",\n      \"billing_interval_unit\": \"Billing interval unit (e.g., month, week)\",\n      \"shipping_interval\": \"Shipping interval count\",\n      \"shipping_interval_unit\": \"Shipping interval unit\",\n      \"trial_period\": \"Trial period count\",\n      \"trial_period_unit\": \"Trial period unit\",\n      \"price_adjustment_type\": \"Discount type: `percentage` or `fixed_amount`\",\n      \"price_adjustment_amount\": \"Discount amount\",\n      \"max_skips\": \"Maximum number of skips allowed\",\n      \"savings_display_mode\": \"How savings are displayed\"\n    },\n    \"config\": \"Raw item configuration object\",\n    \"product_id\": \"ID of the product the item's variant belongs to\",\n    \"product_title\": \"Title of the product the item's variant belongs to\",\n    \"product_image_url\": \"Image URL of the product the item's variant belongs to\",\n    \"product_bundles <Array>\": \"Statically bundled products for this item's product (same shape as `product.product_bundles[]`)\",\n    \"variant\": \"Variant drop for the bundle item (same shape as `products[].variants[]`)\",\n    \"in_stock\": \"Boolean — whether the bundle item is in stock\",\n    \"available_quantity\": \"Available quantity (number of sets) for the bundle item, or null if untracked\"\n  },\n  \"created_at\": \"ISO8601 creation timestamp (or null)\",\n  \"updated_at\": \"ISO8601 update timestamp (or null)\"\n}\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"product.product_bundles--productbundle"},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product.product_bundles[]"]}," — ProductBundle"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Each entry is a single statically-bundled product/variant included with the parent product."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"json","header":{"controls":{"copy":{}}},"source":"{\n  \"id\": \"Product bundle ID\",\n  \"quantity\": \"Quantity of the bundled product\",\n  \"product_id\": \"ID of the parent product that owns the bundle\",\n  \"bundled_variant_id\": \"ID of the bundled variant\",\n  \"tax_percentage\": \"Tax percentage for this bundled item\",\n  \"display_externally\": \"Boolean — whether to display this item on the storefront\",\n  \"title\": \"Bundled product title (null if the bundled variant is missing)\",\n  \"image_url\": \"Bundled product image URL (null if the bundled variant is missing)\",\n  \"bundled_product\": \"Product drop for the bundled product (null if the variant was discarded)\",\n  \"bundled_variant\": \"Variant drop for the bundled variant (same shape as `products[].variants[]`)\",\n  \"in_stock\": \"Boolean — whether the bundled variant is in stock\",\n  \"available_quantity\": \"Available quantity (number of sets) for the bundled variant, or null if untracked\"\n}\n","lang":"json"},"children":[]},{"$$mdtype":"Tag","name":"hr","attributes":{},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"building-a-bundle-section"},"children":["Building a bundle section"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["A bundle product is just a product that exposes ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product_bundle_groups"]},". To build a custom bundle builder, you follow six steps: ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["embed the data → render groups & items → track selections → enforce each group's rule → show a live total → submit to the cart."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The reference implementation is the global ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product_bundle"]}," section (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["app/themes/templates/global/sections/product_bundle/index.liquid"]},"). This guide distills its core flow into the minimum that works — it deliberately leaves out the advanced features (see ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["What this guide leaves out"]}," at the end). The server always re-validates the submitted bundle (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["CartBundleValidator"]},"), so the client logic here is for UX, not enforcement."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"1.-embed-the-bundle-data"},"children":["1. Embed the bundle data"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Render the bundle product as JSON into a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["<script>"]}," tag so your JavaScript can read the whole structure once. Guard it so it only renders for actual bundles:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"html","header":{"controls":{"copy":{}}},"source":"{'%' if product.product_bundle_groups and product.product_bundle_groups.size > 0 '%'}\n  <script type=\"application/json\" data-bundle-data>{{ product | json }}</script>\n{'%' endif '%'}\n","lang":"html"},"children":[]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Place the section on the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["product"]}," template (where ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product"]}," is in scope). For a standalone landing page, add a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"type\": \"product\", \"id\": \"bundle_product\" }"]}," schema setting and use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["section.settings.bundle_product"]}," instead — that is what the reference section does."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"2.-render-the-groups-and-items"},"children":["2. Render the groups and items"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Loop the groups (ordered by ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sort_order"]},") and their items. Mark each card with the data the script needs, and distinguish ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["customizable"]}," groups (the shopper picks) from ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["included"]}," groups (fixed, read-only):"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"html","header":{"controls":{"copy":{}}},"source":"<div data-bundle-builder>\n  {'%' assign groups = product.product_bundle_groups | sort: 'sort_order' '%'}\n  {'%' for group in groups '%'}\n    <div class=\"bundle-group\" data-group-id=\"{{ group.id }}\" data-max=\"{{ group.max_selections | default: 0 }}\">\n      <h3>{{ group.title }}</h3>\n\n      {'%' for item in group.bundle_group_items '%'}\n        <div class=\"bundle-card\" data-variant-id=\"{{ item.variant.id }}\">\n          <span>{{ item.product_title | default: item.variant.title }}</span>\n          <span>{{ item.price }}</span>\n          {'%' if group.group_type == 'customizable' '%'}\n            <button type=\"button\" data-add>Add</button>\n          {'%' else '%'}\n            <span>{{ item.quantity }}× included</span>\n          {'%' endif '%'}\n        </div>\n      {'%' endfor '%'}\n    </div>\n  {'%' endfor '%'}\n\n  <div class=\"bundle-summary\">\n    <strong>Total: <span data-total>0.00</span></strong>\n    <button type=\"button\" data-add-bundle disabled>Add Bundle to Cart</button>\n  </div>\n</div>\n","lang":"html"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"35.-track-selections-enforce-the-rule-show-the-total"},"children":["3–5. Track selections, enforce the rule, show the total"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["A small script holds the selection state, enforces each group's ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["selection_type"]},", computes a running total, and gates the \"Add Bundle to Cart\" button until every group is complete. The ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["groupComplete"]}," helper mirrors ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["isGroupComplete"]}," in the reference section — see the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["selection_type"]}," field in the ProductBundleGroup reference above for the four rules."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"html","header":{"controls":{"copy":{}}},"source":"<script>\n(function () {\n  var root = document.querySelector('[data-bundle-builder]');\n  var data = JSON.parse(document.querySelector('[data-bundle-data]').textContent);\n  var bundleVariantId = data.selected_or_first_available_variant.id;\n  var selections = {}; // { groupId: { variantId: qty } }\n\n  function countFor(gid) {\n    var sel = selections[gid] || {}, n = 0;\n    for (var v in sel) n += sel[v];\n    return n;\n  }\n\n  // Completion rule per group — mirrors isGroupComplete in the reference section.\n  function groupComplete(group, count) {\n    if (group.group_type !== 'customizable') return true;\n    var min = group.min_selections || 0;\n    var max = group.max_selections || Infinity;\n    switch (group.selection_type) {\n      case 'exact':    return count === min;\n      case 'min_only': return count >= min;\n      case 'max_only': return true;\n      default:         return count >= min && count <= max; // min_and_max\n    }\n  }\n\n  // Add an item, respecting the group's max_selections.\n  root.addEventListener('click', function (e) {\n    var btn = e.target.closest('[data-add]');\n    if (!btn) return;\n    var groupEl = btn.closest('.bundle-group');\n    var gid = groupEl.dataset.groupId;\n    var vid = btn.closest('.bundle-card').dataset.variantId;\n    var max = parseInt(groupEl.dataset.max) || Infinity;\n    selections[gid] = selections[gid] || {};\n    if (countFor(gid) >= max) return;\n    selections[gid][vid] = (selections[gid][vid] || 0) + 1;\n    refresh();\n  });\n\n  // Recompute total + enable checkout when every group is complete.\n  function refresh() {\n    var total = 0, complete = true;\n    data.product_bundle_groups.forEach(function (group) {\n      var sel = selections[group.id] || {};\n      for (var vid in sel) {\n        var item = (group.bundle_group_items || []).find(function (i) {\n          return i.variant && String(i.variant.id) === vid;\n        });\n        if (item) total += parseFloat(item.price || 0) * sel[vid];\n      }\n      if (!groupComplete(group, countFor(group.id))) complete = false;\n    });\n    root.querySelector('[data-total]').textContent = total.toFixed(2);\n    root.querySelector('[data-add-bundle]').disabled = !complete;\n  }\n\n  refresh();\n\n  // Wire the \"Add Bundle to Cart\" button here (step 6) — it shares\n  // data, selections, and bundleVariantId from this same closure.\n})();\n</script>\n","lang":"html"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"6.-add-the-bundle-to-the-cart"},"children":["6. Add the bundle to the cart"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["On checkout, build the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bundled_items"]}," payload from the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["customizable"]}," selections — plain ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["included"]},"-group items are reconstituted server-side (submitting them too is rejected as a duplicate). The one exception is ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["included"]}," groups that belong to a ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["mutually exclusive set"]},": those ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["must"]}," be submitted so the server knows which branch was chosen — the Mutual exclusivity section below shows the one-line guard change for that case. Add this inside the same IIFE (it shares ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["data"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["selections"]},", and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bundleVariantId"]},"):"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"root.querySelector('[data-add-bundle]').addEventListener('click', function () {\n  var bundledItems = [];\n  data.product_bundle_groups.forEach(function (group) {\n    if (group.group_type !== 'customizable') return; // included items reconstituted server-side (see exclusivity exception below)\n    var sel = selections[group.id] || {};\n    for (var vid in sel) {\n      bundledItems.push({\n        variant_id: parseInt(vid),\n        quantity: sel[vid],\n        product_bundle_group_id: parseInt(group.id)\n      });\n    }\n  });\n\n  window.FairShareSDK.addCartItems(bundleVariantId, {\n    quantity: 1,\n    bundled_items: bundledItems\n  });\n});\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["addCartItems(bundleVariantId, options)"]}," is the integration contract. Each ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bundled_items"]}," entry needs ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["variant_id"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["quantity"]},", and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product_bundle_group_id"]},". The server (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["CartBundleValidator"]},") validates selection counts, availability, mutual exclusivity, pricing, and subscription rules, then recomputes the price — so a tampered client payload is caught at checkout."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"mutual-exclusivity"},"children":["Mutual exclusivity"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Some groups are wired so that ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["only one of them can be active at a time"]}," — e.g. \"Choose your free gift: A ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["or"]}," B\". This lives on ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["product.mutually_exclusive_groups"]},", a list of sets. Two things to know:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Members are referenced by group ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sort_order"]}]},", not ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["id"]}," — so map them once."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["A set is either a bare array (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["[0, 1]"]},") or an object carrying a default branch (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["{ \"ids\": [0, 1], \"default\": 0 }"]},")."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Build the set membership up front:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"// mutually_exclusive_groups members are group SORT_ORDERS, not ids.\nfunction buildExclusiveSets(data) {\n  var sortToId = {};\n  data.product_bundle_groups.forEach(function (g) { sortToId[g.sort_order] = String(g.id); });\n  return (data.mutually_exclusive_groups || []).map(function (set) {\n    var sortOrders = Array.isArray(set) ? set : (set.ids || []);\n    return {\n      ids:       sortOrders.map(function (so) { return sortToId[so]; }).filter(Boolean),\n      defaultId: (set && set.default != null) ? sortToId[set.default] : null\n    };\n  });\n}\n\nfunction siblingsOf(sets, gid) {\n  var out = [];\n  sets.forEach(function (set) {\n    if (set.ids.indexOf(String(gid)) === -1) return;\n    set.ids.forEach(function (id) { if (id !== String(gid)) out.push(id); });\n  });\n  return out;\n}\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["First, alongside ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["selections"]}," at the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["top of the IIFE"]}," (page scope — these must persist across clicks, so they do ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["not"]}," live inside the handler):"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"var exclusiveSets  = buildExclusiveSets(data);\nvar disabledGroups = {}; // exclusive branches the shopper isn't using\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Then, ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["inside"]}," the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["[data-add]"]}," click handler — picking in one branch clears its siblings and marks them disabled so the completeness check can skip them:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"// inside the [data-add] click handler, before adding the item:\nsiblingsOf(exclusiveSets, gid).forEach(function (sib) {\n  selections[sib] = {};       // discard the losing branch's selected items\n  disabledGroups[sib] = true; // and skip it in refresh()\n});\ndelete disabledGroups[gid];   // the branch just picked is active again\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["A cleared branch keeps empty selections, so ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["refresh()"]}," ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["must"]}," skip disabled groups — otherwise a customizable branch (e.g. ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["min_selections: 1"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["selection_type: 'exact'"]},") stays incomplete and pins the button disabled forever. Update its per-group completeness check:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"// in refresh(), replacing the completeness line:\nif (!disabledGroups[group.id] && !groupComplete(group, countFor(group.id))) complete = false;\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["On first load, if a set declares a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["default"]},", pre-select that branch and clear the others so the shopper never starts in a two-branch conflict:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"exclusiveSets.forEach(function (set) {\n  if (!set.defaultId) return;\n  set.ids.forEach(function (id) {\n    if (id !== set.defaultId) { selections[id] = {}; disabledGroups[id] = true; }\n  });\n});\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Checkout exception."]}," When a set includes an ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["included"]}," (static) branch, that branch's items ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["must"]}," be submitted — otherwise the server can't tell which branch was chosen and rejects the cart with a 422 (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["mutually_exclusive_groups_selected"]},"). Adjust the step 6 guard so exclusive branches are never skipped (here ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["exclusiveSets"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["siblingsOf"]}," are in scope):"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"data.product_bundle_groups.forEach(function (group) {\n  var inExclusive = siblingsOf(exclusiveSets, group.id).length > 0;\n  if (group.group_type !== 'customizable' && !inExclusive) return; // skip only plain included groups\n  // ...build and push each entry exactly as in step 6\n});\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The reference section also adds the ",{"$$mdtype":"Tag","name":"em","attributes":{},"children":["optional"]}," UX around this: a per-group ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["\"Choose this group\""]}," button, visually dimming the inactive branch, and re-rendering sibling cards. (Skipping ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["disabledGroups"]}," in ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["refresh()"]},", shown above, is required for a working button — not polish.) See ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["getMutuallyExclusiveGroupIds"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["updateMutualExclusivity"]}," in ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["index.liquid"]},"."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"subscriptions"},"children":["Subscriptions"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Bundles support ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["two independent subscription layers"]},". Both rely on ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["subscription_plans"]}," (the plan list documented above) and both ride along in the same ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["addCartItems"]}," call."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":4,"id":"per-item-subscriptions"},"children":["Per-item subscriptions"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["An item whose product has ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["subscription_plans"]}," can be subscribed on its own. The mode is derived exactly like the reference's ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["resolveItemSubscriptionMode"]},":"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"// \"hidden\" | \"optional\" | \"forced\"\nfunction itemSubMode(item, group) {\n  if (!(item.subscription_plans || []).length) return 'hidden';\n  if (item.force_subscription || group.force_subscriptions) return 'forced';\n  if (item.allow_subscription) return 'optional';\n  return 'hidden';\n}\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Render a Subscribe checkbox for ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["optional"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["forced"]}," items (checked ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["and"]}," disabled when ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["forced"]},"), plus a plan ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["<select>"]}," when the product has 2+ plans. Track the choice keyed by group + variant:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"var itemSubs = {}; // { groupId: { variantId: { active, planId } } }\n\n// ...when the customer ticks the checkbox / picks a plan:\nitemSubs[gid] = itemSubs[gid] || {};\nitemSubs[gid][vid] = { active: checkbox.checked, planId: parseInt(planSelect.value) };\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":4,"id":"bundle-wide-subscription"},"children":["Bundle-wide subscription"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The whole bundle can be sold as a subscription using the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["bundle product's own"]}," plans (",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["data.subscription_plans"]},"). Offer a one-time vs. subscribe toggle; if there are no bundle plans, omit the toggle entirely:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"var bundlePlans = data.subscription_plans || []; // empty -> no bundle-wide option\nvar bundleSub = { active: false, planId: null };\n\n// ...on the subscribe radio / plan select:\nbundleSub.active = (radio.value === 'subscription' && radio.checked);\nbundleSub.planId = parseInt(planSelect.value) || (bundlePlans[0] && bundlePlans[0].id);\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":4,"id":"attaching-both-at-checkout"},"children":["Attaching both at checkout"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Extend the step-6 payload. Per-item subscriptions decorate each ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bundled_items"]}," entry; the bundle-wide subscription attaches to the ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["top-level"]}," options — and note the top-level key is ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["subscribe"]},", ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["not"]}," ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["subscription"]},":"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"js","header":{"controls":{"copy":{}}},"source":"// build each entry as a variable so you can decorate it:\nfor (var vid in sel) {\n  var entry = {\n    variant_id: parseInt(vid),\n    quantity: sel[vid],\n    product_bundle_group_id: parseInt(group.id)\n  };\n  var sub = (itemSubs[group.id] || {})[vid];\n  if (sub && sub.active && sub.planId) {\n    entry.subscription = true;\n    entry.subscription_plan_id = sub.planId;\n  }\n  bundledItems.push(entry);\n}\n\n// after building bundledItems:\nvar options = { quantity: 1, bundled_items: bundledItems };\nif (bundleSub.active && bundleSub.planId) {\n  options.subscribe = true;                 // top-level uses `subscribe`, not `subscription`\n  options.subscription_plan_id = bundleSub.planId;\n}\nwindow.FairShareSDK.addCartItems(bundleVariantId, options);\n","lang":"js"},"children":[]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["CartBundleValidator"]}," confirms each ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["subscription_plan_id"]}," belongs to the item's (or bundle's) product, so no client-side ownership check is needed. A plan's ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["price_adjustment_type"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["price_adjustment_amount"]}," are informational for showing savings — the charged amount is always recomputed server-side."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"what-this-guide-leaves-out"},"children":["What this guide leaves out"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The reference section also handles the following. Reach for it (and the variable reference above) when you need them — each maps to fields already documented on the drops:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Quantity steppers & per-item ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["max_quantity"]}]}," — ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["+"]},"/",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["−"]}," controls instead of a single Add."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Country / region pricing & availability"]}," — ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["country_pricing"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["country_prices"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["available_country_codes"]},", and bundle-level ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["bundle_pricing_config"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Stock states"]}," — ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["in_stock"]}," / ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["available_quantity"]}," for out-of-stock and low-stock messaging."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Progress UI"]}," — a completion bar driven by each group's required count."]}]},{"$$mdtype":"Tag","name":"blockquote","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Full reference: ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["app/themes/templates/global/sections/product_bundle/index.liquid"]},"."]}]}]},"headings":[{"value":"Product Bundles","id":"product-bundles","depth":1},{"value":"Where bundles are exposed","id":"where-bundles-are-exposed","depth":2},{"value":"product.product_bundle_groups[] — ProductBundleGroup","id":"product.product_bundle_groups--productbundlegroup","depth":2},{"value":"product.product_bundles[] — ProductBundle","id":"product.product_bundles--productbundle","depth":2},{"value":"Building a bundle section","id":"building-a-bundle-section","depth":2},{"value":"1. Embed the bundle data","id":"1.-embed-the-bundle-data","depth":3},{"value":"2. Render the groups and items","id":"2.-render-the-groups-and-items","depth":3},{"value":"3–5. Track selections, enforce the rule, show the total","id":"35.-track-selections-enforce-the-rule-show-the-total","depth":3},{"value":"6. Add the bundle to the cart","id":"6.-add-the-bundle-to-the-cart","depth":3},{"value":"Mutual exclusivity","id":"mutual-exclusivity","depth":3},{"value":"Subscriptions","id":"subscriptions","depth":3},{"value":"Per-item subscriptions","id":"per-item-subscriptions","depth":4},{"value":"Bundle-wide subscription","id":"bundle-wide-subscription","depth":4},{"value":"Attaching both at checkout","id":"attaching-both-at-checkout","depth":4},{"value":"What this guide leaves out","id":"what-this-guide-leaves-out","depth":3}],"frontmatter":{"seo":{"title":"Product Bundles"}},"lastModified":"2026-06-05T20:13:07.000Z"},"slug":"/docs/themes/product-bundles","userData":{"isAuthenticated":false,"teams":["anonymous"]}}