Dynamic Box Cart Rollup

Implementing Dynamic BaB Cart Rollup in Shopify Carts

Introduction

Dynamic BaB Cart Rollup allows merchants to display bundled products in a Shopify cart in a way that prevents customers from updating individual items within the bundle. This is important in order to enforce minimums and maximums. This guide outlines the steps to implement that rollup.

Preliminary Note

Before starting, ensure that you can edit the Shopify cart. Some merchants use third-party cart apps (like rebuy-cart or icart), which can make it challenging to apply custom changes. If you can't edit the loop over cart items (for item in cart.items), implementing Cart Rollup might be difficult.

Example Implementation: Apothecary

Apothecary serves as an excellent implementation example for Cart Rollup. Select four items here and then click Continue then Add to Cart to see the Rollup implementation. Select a few different products to see the full effect of the rollup.

Summary of Changes

Implementation details in next section

  1. Create an array named bundles_displayed.
  2. Loop over each item in the cart.
  3. If an item has a _bundleUid property and it's not in bundles_displayed yet, add the _bundleUid to bundles_displayed and render a special bundle_cart_item snippet.
  4. Otherwise, render the theme’s standard cart item liquid.

Customization Steps

  1. Work in an unpublished duplicated version of your live theme (avoid editing the published theme's cart page directly).

  2. Add a new file bundle-cart-item.liquid in the snippets folder.

  3. Locate the liquid file where the theme's cart items are looped over (commonly in cart-template.liquid). Search for for item in cart.items if you can't find it.

  4. Ensure there's only one file needing an update. Sometimes, this loop could exist in multiple places.

  5. At the top of the identified file(s), create a new variable for an array of bundleUids:

    {% assign bundles_displayed = '' | split:',' %}
    
  6. Copy the contents inside the for loop into bundle-cart-item.liquid.

  7. Update the for loop with the branches as discussed in the summary. Example:

    1. {%- for item in cart.items -%}
          {%- if item.properties['_bundleUid'] -%}
              {%- unless bundles_displayed contains item.properties['_bundleUid'] -%}
                  {%- assign bundles_displayed = item.properties['_bundleUid'] | concat: bundles_displayed -%}
                  {%- render 'bundle-cart-item', item: item, cart: cart, current_bundleUid: item.properties['_bundleUid'], bundleHandle: item.properties['_bundleHandle'] -%}
              {%- endunless -%}
          {%- else -%}
              <div class="card product " data-cart-item>
                  ...
                  ...the rest of the item liquid...
                  ...
              </div>
          {%- endif -%}
      {%- endfor -%}
      
    2. sometime the loop looks like {%- for line_item in cart.items -%} instead, if so replace uses of item with line_item below that in our example above
  8. At the top of bundle-cart-item.liquid add the following code:

    1. {% assign bundle_price = 0 %}
      {% assign bundle_product = all_products[bundleHandle] %}
      {% assign bundle_product_image = '' %}
      {% if bundle_product.featured_media %}
          {% if bundle_product.featured_media.media_type == "video" %}
              {% capture bundle_product_image %}{{ bundle_product.featured_media | image_url: width: 300, height: 300, format: "jpg" }}{% endcapture %}
          {% endif %}
          {% if bundle_product.featured_media.media_type == "image" %}
              {% capture bundle_product_image %}{{ bundle_product.featured_media | image_url: width: 300, height: 300 }}{% endcapture %}
          {% endif %}
      {% endif %}
      
  9. Modify bundle-cart-item.liquid: Replace instances of item.ANY_PROPERTY with bundle_product.SAME_PROPERTY, keeping some exceptions in mind.

    1. For example, replace item.url with bundle_product.url
  10. Replace cart item image rendering with bundle_product_image.

  11. Adjust the image size as needed (lines 6 & 9 in the provided snippet).

  12. Replace the title and variant title with bundle_product's respective titles.

  13. Add selling plan allocation information.

  14. Remove any rendered item.properties loop.

  15. List out all items in the bundle, adding up their price for the full line item price in the variable bundle_price from line one of step 8

    1. {%- for item in cart.items -%}
              {%- if item.properties['_bundleUid'] == current_bundleUid -%}
                  <div class="bundle-subitem" style="display: flex; align-items: center;">
                      {% if item.image %}
                          {% comment %} Leave empty space due to a:empty CSS display: none rule {% endcomment %}
                          <img class="cart-item__image"
                              src="{{ item.image | img_url: '30x' }}"
                              alt="{{ item.image.alt | escape }}"
                              loading="lazy"
                              width="30"
                              height="{{ 30 | divided_by: item.image.aspect_ratio | ceil }}"
                              style="margin-right: 5px;"
                          >
                      {% endif %}
                      <div class="bundle-subitem-details">
                          {%- assign bundle_price = item.price | times: item.quantity | plus: bundle_price -%}
                          <div class="product-option">
                              <span>{{ item.title }}{%- if item.has_only_default_variant == false -%} | {{ item.variant.title }}{%- endif -%}:
                              </span>
                              <span>{{ item.quantity }}</span>
      
                          </div>
                      </div>
                  </div>
              {%- endif -%}
          {%- endfor -%}
      
  16. Find and replace the quantity input and buttons with a value of 1 (disabled, with no buttons).

  17. Replace any price labels with the calculated price in variable bundle_price

    1. {{ bundle_price | money }}
  18. Update the standard Remove button functionality:

    • Remove href attribute if using an anchor tag and add class name cart__remove-bundle with a data attribute data-bundle-id="{{ current_bundleUid }}".
    • If a class name or data attribute is used in custom JS, modify it to use cart__remove-bundle and add the data attribute.

Adding Custom JavaScript

Add custom JavaScript to create a listener for the remove button. This script should target all elements with the cart__remove-bundle class name and add a click listener to remove all related bundle items. Example:

{% comment %} BUNDLE JS {% endcomment %}
<script>
// Registers each cart bundle item's remove button
document.body.addEventListener('click', async (event) => {
  if(!event.target.matches('.cart__remove-bundle')) return;
  const bundle_id = event.target.getAttribute('data-bundle-id');

  try {
    // Get current cart contents
    const cart = await (await fetch('/cart.js')).json();

    // Create data object for cart update AJAX call
    const data = {
      updates: cart.items.reduce((acc, item) => (
        (item.properties?._bundleUid == bundle_id) ? { ...acc, [item.key]: 0 } : acc
      ), {})
    };

    // API call to update the cart contents and set the bundle item quantities to 0
    const response = await fetch('/cart/update.js', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });

    response.ok && (window.location.href = '/cart');
  } catch (error) {
    console.error(error);
  }
});
</script>

Notes

  • If the faux bundle product is in draft mode, its image or title may not be accessible in liquid so the image will be blank until you make that product active

Conclusion

This guide outlines the necessary steps to implement Dynamic BaB in Shopify carts. Remember to test these changes thoroughly in a backup theme before applying them to your live store.

Full examlpe of bundle-cart-item.liquid

This is just an example and shouldn't be fully copied because each theme is different

{% assign bundle_price = 0 %}
{% assign bundle_product = all_products[bundleHandle] %}
{% assign bundle_product_image = '' %}
{% if bundle_product.featured_media %}
    {% if bundle_product.featured_media.media_type == "video" %}
        {% capture bundle_product_image %}{{ bundle_product.featured_media | image_url: width: 300, height: 300, format: "jpg" }}{% endcapture %}
    {% endif %}
    {% if bundle_product.featured_media.media_type == "image" %}
        {% capture bundle_product_image %}{{ bundle_product.featured_media | image_url: width: 300, height: 300 }}{% endcapture %}
    {% endif %}
{% endif %}


<tr class="cart-item" id="CartItem-{{ item.index | plus: 1 }}">
    <td class="cart-item__media">
    {% if bundle_product_image %}
        {% comment %} Leave empty space due to a:empty CSS display: none rule {% endcomment %}
        <a href="{{ bundle_product.url }}" class="cart-item__link" aria-hidden="true" tabindex="-1"> </a>
        <img class="cart-item__image"
        src="{{ bundle_product_image }}"
        loading="lazy"
        width="150"
        height="150"
        {% comment %} height="{{ 150 | divided_by: item.image.aspect_ratio | ceil }}" {% endcomment %}
        >
    {% endif %}
    </td>

    <td class="cart-item__details">
    {%- if section.settings.show_vendor -%}
        <p class="caption-with-letter-spacing light">{{ bundle_product.vendor }}</p>
    {%- endif -%}

    <a href="{{ bundle_product.url }}" class="cart-item__name h4 break"><b>{{ bundle_product.title | escape }}</b></a>

    {%- if item.selling_plan_allocation != nil -%}
        <small style="margin-top">({{ item.selling_plan_allocation.selling_plan.name }})</small>
    {%- else -%}
        <small style="margin-top">(One-time)</small>
    {%- endif -%}

    {%- for item in cart.items -%}
        {%- if item.properties['_bundleUid'] == current_bundleUid -%}
            <div class="bundle-subitem" style="display: flex; align-items: center;">
                {% if item.image %}
                    {% comment %} Leave empty space due to a:empty CSS display: none rule {% endcomment %}
                    <img class="cart-item__image"
                        src="{{ item.image | img_url: '30x' }}"
                        alt="{{ item.image.alt | escape }}"
                        loading="lazy"
                        width="30"
                        height="{{ 30 | divided_by: item.image.aspect_ratio | ceil }}"
                        style="margin-right: 5px;"
                    >
                {% endif %}
                <div class="bundle-subitem-details">
                    {%- assign bundle_price = item.price | times: item.quantity | plus: bundle_price -%}
                    <div class="product-option">
                        <span>{{ item.title }}{%- if item.has_only_default_variant == false -%} | {{ item.variant.title }}{%- endif -%}:
                        </span>
                        <span>{{ item.quantity }}</span>

                    </div>
                </div>
            </div>
        {%- endif -%}
    {%- endfor -%}

    <ul class="discounts list-unstyled" role="list" aria-label="{{ 'customer.order.discount' | t }}">
        {%- for discount in item.discounts -%}
        <li class="discounts__discount">
            {%- render 'icon-discount' -%}
            {{ discount.title }}
        </li>
        {%- endfor -%}
    </ul>

    <div class="cart-item__error" id="Line-item-error-{{ item.index | plus: 1 }}" role="alert">
        <small class="cart-item__error-text"></small>
        <svg aria-hidden="true" focusable="false" role="presentation" class="icon icon-error" viewBox="0 0 13 13">
        <circle cx="6.5" cy="6.50049" r="5.5" stroke="white" stroke-width="2"/>
        <circle cx="6.5" cy="6.5" r="5.5" fill="#EB001B" stroke="#EB001B" stroke-width="0.7"/>
        <path d="M5.87413 3.52832L5.97439 7.57216H7.02713L7.12739 3.52832H5.87413ZM6.50076 9.66091C6.88091 9.66091 7.18169 9.37267 7.18169 9.00504C7.18169 8.63742 6.88091 8.34917 6.50076 8.34917C6.12061 8.34917 5.81982 8.63742 5.81982 9.00504C5.81982 9.37267 6.12061 9.66091 6.50076 9.66091Z" fill="white"/>
        <path d="M5.87413 3.17832H5.51535L5.52424 3.537L5.6245 7.58083L5.63296 7.92216H5.97439H7.02713H7.36856L7.37702 7.58083L7.47728 3.537L7.48617 3.17832H7.12739H5.87413ZM6.50076 10.0109C7.06121 10.0109 7.5317 9.57872 7.5317 9.00504C7.5317 8.43137 7.06121 7.99918 6.50076 7.99918C5.94031 7.99918 5.46982 8.43137 5.46982 9.00504C5.46982 9.57872 5.94031 10.0109 6.50076 10.0109Z" fill="white" stroke="#EB001B" stroke-width="0.7">
        </svg>
    </div>
    </td>

    <td class="cart-item__totals right medium-hide large-up-hide">
    <div class="loading-overlay hidden">
        <div class="loading-overlay__spinner">
        <svg aria-hidden="true" focusable="false" role="presentation" class="spinner" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
            <circle class="path" fill="none" stroke-width="6" cx="33" cy="33" r="30"></circle>
        </svg>
        </div>
    </div>
    <div class="cart-item__price-wrapper">
        <span class="price price--end">
            {{ bundle_price | money }}
        </span>
    </div>
    </td>

    <td class="cart-item__quantity">
    <label class="visually-hidden" for="Quantity-{{ item.index | plus: 1 }}">
        {{ 'products.product.quantity.label' | t }}
    </label>

    {% comment %} replace quantity with hidden input {% endcomment %}
    <input class="quantity" value="1" readonly="" style="text-align: center;">

    {% comment %} replace remove button {% endcomment %}
    {% comment %} <cart-remove-button id="Remove-{{ item.index | plus: 1 }}" data-index="{{ item.index | plus: 1 }}">
        <a href="{{ item.url_to_remove }}" class="button button--tertiary" aria-label="{{ 'sections.cart.remove_title' | t: title: item.title }}">
        {% render 'icon-remove' %}
        </a>
    </cart-remove-button> {% endcomment %}
    <cart-remove-button class="cart__remove-bundle" data-bundle-id="{{ current_bundleUid }}">
        <a href="{{ item.url_to_remove }}" class="button button--tertiary" aria-label="{{ 'sections.cart.remove_title' | t: title: item.title }}">
            {% render 'icon-remove' %}
        </a>
    </cart-remove-button>

    </td>

    <td class="cart-item__totals right small-hide">
    <div class="loading-overlay hidden">
        <div class="loading-overlay__spinner">
        <svg aria-hidden="true" focusable="false" role="presentation" class="spinner" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
            <circle class="path" fill="none" stroke-width="6" cx="33" cy="33" r="30"></circle>
        </svg>
        </div>
    </div>

    <div class="cart-item__price-wrapper">
        <span class="price price--end">
            {{ bundle_price | money }}
        </span>

    </div>
    </td>
</tr>