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
- Create an array named
bundles_displayed
. - Loop over each item in the cart.
- If an item has a
_bundleUid
property and it's not inbundles_displayed
yet, add the_bundleUid
tobundles_displayed
and render a specialbundle_cart_item
snippet. - Otherwise, render the theme’s standard cart item liquid.
Customization Steps
-
Work in an unpublished duplicated version of your live theme (avoid editing the published theme's cart page directly).
-
Add a new file
bundle-cart-item.liquid
in the snippets folder. -
Locate the liquid file where the theme's cart items are looped over (commonly in
cart-template.liquid
). Search forfor item in cart.items
if you can't find it. -
Ensure there's only one file needing an update. Sometimes, this loop could exist in multiple places.
-
At the top of the identified file(s), create a new variable for an array of
bundleUids
:{% assign bundles_displayed = '' | split:',' %}
-
Copy the contents inside the for loop into
bundle-cart-item.liquid
. -
Update the for loop with the branches as discussed in the summary. Example:
-
{%- 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 -%}
- sometime the loop looks like
{%- for line_item in cart.items -%}
instead, if so replace uses ofitem
withline_item
below that in our example above
-
-
At the top of
bundle-cart-item.liquid
add the following code:-
{% 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 %}
-
-
Modify
bundle-cart-item.liquid
: Replace instances ofitem.ANY_PROPERTY
withbundle_product.SAME_PROPERTY
, keeping some exceptions in mind.- For example, replace
item.url
withbundle_product.url
- For example, replace
-
Replace cart item image rendering with
bundle_product_image
. -
Adjust the image size as needed (lines 6 & 9 in the provided snippet).
-
Replace the title and variant title with
bundle_product
's respective titles. -
Add selling plan allocation information.
-
Remove any rendered
item.properties
loop. -
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-
{%- 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 -%}
-
-
Find and replace the quantity input and buttons with a value of 1 (disabled, with no buttons).
-
Replace any price labels with the calculated price in variable
bundle_price
{{ bundle_price | money }}
-
Update the standard Remove button functionality:
- Remove
href
attribute if using an anchor tag and add class namecart__remove-bundle
with a data attributedata-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.
- Remove
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
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>
Updated about 1 year ago