How to Create a Dynamic Product Bundling Experience in BigCommerce
Product bundling is a powerful strategy for increasing average order value (AOV) and enhancing the shopping experience. By allowing customers to create their own bundles, you can offer flexibility and personalization, which can lead to higher customer satisfaction and sales. In this guide, we’ll walk you through creating a dynamic product bundling experience in BigCommerce using Stencil, TailwindCSS, DaisyUI, and GraphQL.
What is Product Bundling?
Product bundling allows customers to purchase multiple products together as a single package. This is especially useful for promotions, gift sets, or customizable product combinations. In BigCommerce, bundling can be implemented using pick list modifier fields, which allow customers to select options dynamically.
For example, a pick list named “Bundle Item 1” might allow customers to choose a product from a specific category. Once a product is selected, the next pick list (“Bundle Item 2”) is loaded, and so on. This creates a seamless, step-by-step bundling experience.
The Challenge
We recently built a new BigCommerce website for Sunnyland Farms, a family-owned business that sells pecans and pecan-based goods. They were looking to move off their custom-built platform and into a more standardized system to help reduce the complexity of running their online store.
As part of this migration into a more standardized setup, we had to take their existing features and any new requirements and find ways to implement them in a way that is compatible with the technical limitations of Bigcommerce and their Stencil theming. One such feature was their product bundling experience.
This gave us the challenge: How can we implement a complex product bundling feature in a system that will have more limitations compared to a fully custom-built store, while also making sure it provides a pleasant shopping experience and doesn’t create a bunch of technical debt for the client?
The Process
Like most e-commerce platforms, BigCommerce provides a way to manage products and product data on the store. Admins can log in, add products, remove products, and edit details about products. Also, like most other platforms, they have integrations for handling the stages of the shopping experience, such as shipping, checkout, and cart management.
With bundling, we had to find a way to manage a one-to-many relationship between the bundle and the products while also making sure this stays compatible with things like adding the bundle to the cart or applying one shipping address to the whole bundle rather than each product individually.
The Solution
We decided to use BigCommerce’s pick list modifier option fields to manage the relationship between bundles and their products. Pick lists can be assigned to the bundle, and then products are assigned as the values to the pick list field. This way, any time details about products are updated, these changes can also be reflected in the bundle as well as on the individual product page.
The downside to using pick lists is that you can only pick one item from a pick list instead of multiple, so when a user goes to select a product from the pick list field, they can only choose one at a time.
To get around this limitation, we decided on an arbitrary limit for how many products a user can add to a bundle (we decided on thirty). Once that was decided, we created 30 pick lists, labelled consistently (Bundle Item 1, Bundle Item 2, etc.), and then assigned all of the possible products to each of the pick list values. This is a little bit cumbersome, but the good part about this approach is that the bundle’s products that are assigned can be edited from within the BigCommerce admin. We also created a script to help with automating the addition of products to bundles.
By handling bundles through pick lists, this solves the requirements of needing to tie in a whole bundle as one product, being able to assign one shipping address to a bundle and being able to possibly add multiple bundles to a cart at one time.
Gift Bows
There was also the requirement to allow customers to add gift bows to bundles if they bought three or more products in a bundle. If they buy any more after that, every set of three can have an additional gift bow added. Since there is a limit of thirty products in a bundle, customers can technically add up to ten gift bows in a whole bundle.
To keep things consistent, we also used pick lists for the bows. There are ten pick list fields (Gift Bow 1, Gift Bow 2, etc.), and each pick list has the gift bow product added as the only value for each of the pick lists. From there, the appropriate gift bow pick list item shows when the correct number of products is added to the bundle. If a user adds many more products, like nine, for example, the first gift bow field shows, and then the next one immediately shows when the customer chooses to add the first one. This continues until the customer hits the limit of how many gift bows they are allowed to add based on the number of products in the bundle.
Setting Up the Template
To keep the bundling experience separate from the default product, created a new custom product template. In this example, a bundle.html template has been created. This file defines the layout and structure of the bundling page. We will add functionality to manage the state of the user’s created bundle later.
Here is an example of the bundle template:
{{#partial "page"}}
<button data-bundle-add-to-cart id="add-bundle-to-cart" class="tw-btn tw-btn-primary tw-sticky tw-top-[98px] tw-z-50 tw-w-full tw-left-0 tw-opacity-100 disabled:tw-hidden md:tw-hidden" disabled>
Add Bundle to Cart <span data-bundle-qty></span>
</button>
<div class="tw-container tw-my-16 md:tw-my-8" id="mix-and-match-page">
{{> components/common/breadcrumbs breadcrumbs=breadcrumbs}}
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-3 tw-gap-8">
<div class="tw-bg-white tw-rounded tw-shadow tw-p-4 md:tw-p-6 md:tw-sticky md:tw-top-14 tw-self-start tw-h-fit">
<h1 class="tw-font-bold tw-mb-2 tw-text-earth tw-text-2xl md:tw-text-3xl tw-text-balance">{{product.title}}</h1>
<div class="tw-my-4">
<div data-bv-show="rating_summary" data-bv-product-id="{{product.sku}}"></div>
</div>
{{#if product.description}}
<div>{{{product.description}}}</div>
{{/if}}
<h2 class="tw-text-xl tw-text-earth tw-text-balance">Choose Three or More Products:</h2>
<div id="selected-items" data-bundle-selected-items class="selected-items tw-flex tw-flex-wrap tw-gap-4 tw-min-h-[140px]"></div>
<div class="tw-my-4">
<strong>Total: </strong> <span data-bundle-total class="tw-font-bold tw-text-primary">$0.00</span>
</div>
<div data-gift-bow-upsell></div>
<button data-bundle-add-to-cart id="add-bundle-to-cart" class="tw-btn tw-btn-primary" disabled>
Add Bundle to Cart
</button>
</div>
<div class="md:tw-col-span-2 tw-bg-white tw-shadow tw-rounded tw-p-4 md:tw-p-6">
<div data-bundle-categories>Loading products...</div>
</div>
</div>
{{{region name="mix_match_below_bundles_list"}}}
</div>
<div data-bundle-loading-overlay class="tw-hidden tw-absolute tw-w-full tw-h-full tw-inset-0 tw-bg-white tw-opacity-50"></div>
{{/partial}}
{{> layout/base}}
{{#partial "page"}}
<button data-bundle-add-to-cart id="add-bundle-to-cart" class="tw-btn tw-btn-primary tw-sticky tw-top-[98px] tw-z-50 tw-w-full tw-left-0 tw-opacity-100 disabled:tw-hidden md:tw-hidden" disabled>
Add Bundle to Cart <span data-bundle-qty></span>
</button>
<div class="tw-container tw-my-16 md:tw-my-8" id="mix-and-match-page">
{{> components/common/breadcrumbs breadcrumbs=breadcrumbs}}
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-3 tw-gap-8">
<div class="tw-bg-white tw-rounded tw-shadow tw-p-4 md:tw-p-6 md:tw-sticky md:tw-top-14 tw-self-start tw-h-fit">
<h1 class="tw-font-bold tw-mb-2 tw-text-earth tw-text-2xl md:tw-text-3xl tw-text-balance">{{product.title}}</h1>
<div class="tw-my-4">
<div data-bv-show="rating_summary" data-bv-product-id="{{product.sku}}"></div>
</div>
{{#if product.description}}
<div>{{{product.description}}}</div>
{{/if}}
<h2 class="tw-text-xl tw-text-earth tw-text-balance">Choose Three or More Products:</h2>
<div id="selected-items" data-bundle-selected-items class="selected-items tw-flex tw-flex-wrap tw-gap-4 tw-min-h-[140px]"></div>
<div class="tw-my-4">
<strong>Total: </strong> <span data-bundle-total class="tw-font-bold tw-text-primary">$0.00</span>
</div>
<div data-gift-bow-upsell></div>
<button data-bundle-add-to-cart id="add-bundle-to-cart" class="tw-btn tw-btn-primary" disabled>
Add Bundle to Cart
</button>
</div>
<div class="md:tw-col-span-2 tw-bg-white tw-shadow tw-rounded tw-p-4 md:tw-p-6">
<div data-bundle-categories>Loading products...</div>
</div>
</div>
{{{region name="mix_match_below_bundles_list"}}}
</div>
<div data-bundle-loading-overlay class="tw-hidden tw-absolute tw-w-full tw-h-full tw-inset-0 tw-bg-white tw-opacity-50"></div>
{{/partial}}
{{> layout/base}}
Key Elements of the Template
The product grid displays products available for selection. This will be dynamically populated using JavaScript. For new, a placeholder:
<div data-bundle-categories>Loading products...</div>
Has been added to let users know that products will load here.
The selected items section shows the products the user has added to the bundle. The selected products will load here:
<div id="selected-items" data-bundle-selected-items class="selected-items tw-flex tw-flex-wrap tw-gap-4 tw-min-h-[140px]"></div>
The bundle total displays the total price of the selected products:
<strong>Total: </strong> <span data-bundle-total class="tw-font-bold tw-text-primary">$0.00</span>
The add to cart button allows users to add the bundle to the cart once the minimum number of products is selected.
<button data-bundle-add-to-cart id="add-bundle-to-cart" class="tw-btn tw-btn-primary" disabled>
Add Bundle to Cart
</button>
The loading overlay prevents user interaction while products are being added or removed.
<div data-bundle-loading-overlay class="tw-hidden tw-absolute tw-w-full tw-h-full tw-inset-0 tw-bg-white tw-opacity-50"></div>
Adding Dynamic Behavior with JavaScript
The mix-match.js file powers the dynamic behavior of the bundling experience. It manages the selected products, fetches data from the BigCommerce GraphQL API, and updates the UI.
Key Features of mix-match.js
- Loading Pick Lists (loadStep):
- Fetches products for the current pick list using the fetchBundleProductPickLists function from product-actions.js.
- Dynamically populates the product grid.
async loadStep() {
const bundleProductOptions = await fetchBundleProductPickLists(
[this.BUNDLE_PRODUCT_ID],
this.context,
this.step,
);
if (!bundleProductOptions || bundleProductOptions.length === 0) {
if (this.step >= MAX_PRODUCTS_PER_BUNDLE) {
this.categoriesContainer.innerHTML = `<div class="tw-p-4 tw-min-h-[500px] tw-flex tw-flex-col tw-items-center tw-justify-center tw-text-center">
<h3 class="h4 tw-text-earth">Limit Reached</h3>
<p>You have reached the maximum allowed number of products in a bundle.
<br />
If you need to order more products, please contact us to make a custom order.</p>
<a href="/contact" class="tw-btn tw-btn-primary">Contact Us</a>
</div>`;
} else {
this.categoriesContainer.innerHTML =
'<p>No products found in Mix & Match!</p>';
}
} else {
const allProducts = [];
await Promise.all(
bundleProductOptions.map(async (option) => {
const productData = await fetchBundledProducts(
option.productId,
this.context,
);
const product = productData.node;
if (!productData || !product) {
console.warn(`No product data found for bundle: ${option.label}`);
return;
}
allProducts.push({
product,
optionId: option.optionId,
optionValue: option.optionValue,
});
}),
);
// Sort all products alphabetically by name
allProducts.sort((a, b) => {
const nameA = a.product.name.toLowerCase();
const nameB = b.product.name.toLowerCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
});
// Clear the container and create a single grid for all products
this.categoriesContainer.innerHTML = '';
// Create the product grid
const grid = document.createElement('div');
grid.className =
'tw-grid tw-grid-cols-2 lg:tw-grid-cols-3 tw-gap-2 md:tw-gap-6';
this.categoriesContainer.appendChild(grid);
// Function to render products
const renderProducts = (products) => {
grid.innerHTML = '';
products.forEach((prod) => {
grid.appendChild(this.createProductCard(prod));
});
};
// Initial render of all products
renderProducts(allProducts);
// Initialize search functionality
this.initializeSearch(allProducts, renderProducts);
}
this.renderSelectedItems();
this.updateBundleTotal();
}
- Adding Products to the Bundle (addToBundle):
- Adds a selected product to the bundle.
- Updates the UI (e.g., disables the “Add” button, shows a toast notification, updates the bundle total).
- Loads the next pick list for further selection.
addToBundle(product, optionId, optionValue, addBtn) {
// Show the loading overlay
this.toggleLoadingOverlay(true);
this.selectedItems.push({
...product,
optionId,
optionValue,
step: this.step,
});
// Add a success class to the button for visual feedback
addBtn.textContent = 'Added!';
addBtn.classList.add('tw-btn-success', 'tw-opacity-80');
// Truncate the product name if it exceeds 30 characters
const truncatedName =
product.name.length > 15
? `${product.name.slice(0, 15)}...`
: product.name;
// Show a toast notification
this.showToast(`${truncatedName} has been added to your bundle!`);
// Update the bundle quantity
this.updateBundleQty();
setTimeout(() => {
// Reset the button text and styles
addBtn.textContent = 'Add';
addBtn.classList.remove('tw-btn-success', 'tw-opacity-80');
// Determine the next step
const filledSteps = this.selectedItems.map((item) => item.step);
const maxStep = Math.max(...filledSteps, 0);
const missingStep = Array.from({ length: maxStep }, (_, i) => i + 1).find(
(step) => !filledSteps.includes(step),
);
this.step = missingStep || maxStep + 1; // Jump to the lowest missing step or increment if all steps are filled
this.loadStep(this.context); // Recursively call loadStep to fetch the next pick list's products
// Hide the loading overlay
this.toggleLoadingOverlay(false);
}, 1000); // Delay to keep the success message visible
}
- Removing Products from the Bundle (removeFromBundle):
- Removes a product from the bundle.
- Updates the UI and adjusts the pick list logic.
async removeFromBundle(optionId) {
// Show the loading overlay
this.toggleLoadingOverlay(true);
const itemIndex = this.selectedItems.findIndex(
(i) => i.optionId === optionId,
);
if (itemIndex === -1) {
console.warn(`Item with optionId ${optionId} not found.`);
return;
}
// If the item to remove is the last item, simply remove it
if (itemIndex === this.selectedItems.length - 1) {
this.selectedItems.splice(itemIndex, 1);
// Update the bundle quantity
this.updateBundleQty();
this.step = this.selectedItems.length + 1;
} else {
// Move the last item to the position of the removed item
const lastItem = this.selectedItems.pop();
// Get the options for the step that was removed
const bundleProductOptions = await fetchBundleProductPickLists(
[this.BUNDLE_PRODUCT_ID],
this.context,
itemIndex + 1,
true,
);
if (bundleProductOptions.length > 0) {
// Update the last item's properties to match the removed item's optionId and optionValue
const removedItem = this.selectedItems[itemIndex];
lastItem.optionId = removedItem.optionId;
lastItem.optionValue = removedItem.optionValue;
// Fetch updated product details for the moved item
const productDetails = bundleProductOptions.find(
(item) => item.label === lastItem.name,
);
if (productDetails) {
lastItem.optionId = productDetails.optionId;
lastItem.optionValue = productDetails.optionValue;
lastItem.step = itemIndex + 1;
}
// Replace the removed item with the updated last item
this.selectedItems[itemIndex] = lastItem;
// Reset step to how many items are selected plus one to get the next step
this.step = this.selectedItems.length + 1;
} else {
// If we can't move the last item to the index of the item the user removed...
//...Remove that item they wanted to remove originally...
this.selectedItems.splice(itemIndex, 1);
//...and update the step so the last item slot removed can be filled
this.step = itemIndex + 1;
}
}
// Re-enable the button for the removed item
const btnRef = this.addButtonsMap[optionId];
if (btnRef) {
btnRef.disabled = false;
}
// Reload the step to fetch the next pick list's products
await this.loadStep(this.context);
// Hide the loading overlay
this.toggleLoadingOverlay(false);
}
- Updating the Bundle Total (updateBundleTotal):
- Calculates the total price of the selected products.
- Enables or disables the “Add to Cart” button based on the minimum product requirement.
updateBundleTotal() {
// Calculate the total price of selected items
const selectedItemsTotal = this.selectedItems.reduce(
(sum, item) => sum + item.price * (item.quantity || 1), // Default quantity to 1 if missing
0,
);
// Calculate the total price of gift bows
const giftBowsTotal = (this.selectedGiftBows || []).reduce(
(sum, bow) => sum + bow.price, // Gift bows don't have a quantity
0,
);
// Calculate the overall total
const total = selectedItemsTotal + giftBowsTotal;
// Count the number of gift bows
const giftBowCount = (this.selectedGiftBows || []).length;
// Update the total price element
const totalPriceEl = document.querySelector('[data-bundle-total]');
if (totalPriceEl) {
totalPriceEl.textContent = `$${total.toFixed(2)}${
giftBowCount > 0
? ` (+${giftBowCount} Gift Bow${giftBowCount > 1 ? 's' : ''})`
: ''
}`;
} else {
console.warn('Total price element not found.');
}
// Calculate the total quantity of products (excluding gift bows)
const totalQty = this.selectedItems.reduce(
(acc, item) => acc + (item.quantity || 1),
0,
);
// Enable or disable the "Add to Cart" button based on the total quantity
this.addToCartBtns.forEach((addToCartBtn) => {
addToCartBtn.disabled = totalQty < 3;
});
}
- Loading Overlay (toggleLoadingOverlay):
- Prevents user interaction during asynchronous operations.
toggleLoadingOverlay(isVisible) {
const overlay = document.querySelector('[data-bundle-loading-overlay]');
if (overlay) {
overlay.classList.toggle('tw-hidden', !isVisible);
}
}
Fetching Data with GraphQL
To help keep things organized, we have created a separate file that houses our calls to the BigCommerce GraphQL API. These functions are used to retrieve product and pick list data, and can be used in other areas of the site where you may need to fetch product data.
Key Functions
- fetchBundleProductPickLists:
- Fetches pick list options for the current step.
- fetchBundledProducts:
- Retrieves detailed product data for the pick list.
- fetchExtendedProductsById:
- Fetches extended product data, including metafields and variants.
Example GraphQL Query:
query getPickListOptions($productId: Int!) {
site {
product(entityId: $productId) {
options {
edges {
node {
displayName
values {
edges {
node {
label
entityId
}
}
}
}
}
}
}
}
}
Enhancing the User Experience
Styling with TailwindCSS and DaisyUI
When building a dynamic product bundling experience in BigCommerce, styling plays a crucial role in creating a visually appealing and user-friendly interface. This project leverages TailwindCSS with a tw- prefix for utility classes and DaisyUI for pre-built components, ensuring a consistent and modern design.
TailwindCSS provides a utility-first approach to styling, allowing you to quickly apply layout, spacing, and typography styles directly in your HTML. For example, the product grid is styled using Tailwind’s grid utilities, such as tw-grid, tw-grid-cols-2, and tw-gap-6, to create a responsive layout that adjusts seamlessly across devices. Similarly, spacing and alignment are handled with classes like tw-p-4 for padding, tw-mb-4 for margins, and tw-flex combined with tw-items-center and tw-justify-center for centering elements. This approach eliminates the need for custom CSS in many cases, speeding up development and ensuring consistency.
DaisyUI, built on top of TailwindCSS, provides pre-designed components like buttons, cards, and alerts that integrate seamlessly with the utility classes. For instance, the “Add to Cart” button uses the tw-btn and tw-btn-primary classes from DaisyUI, giving it a polished appearance with hover and focus states out of the box. Toast notifications, which confirm when a product is added to the bundle, are styled using DaisyUI’s tw-alert component, combined with Tailwind utilities like tw-shadow-lg and tw-rounded-lg for a modern, clean look. By leveraging DaisyUI, you can maintain a consistent design language across the bundling experience while reducing the need for custom component development.
Together, TailwindCSS and DaisyUI provide a powerful combination for building responsive, accessible, and visually appealing interfaces. They allow developers to focus on functionality while ensuring the design remains cohesive and professional.
Toast Notifications
To help give mobile users feedback on when a product has successfully been added to their bundle, we have added toast messages:
showToast(message) {
const toast = document.createElement('div');
toast.className = 'tw-alert tw-alert-success tw-shadow-lg tw-px-4 tw-py-2 tw-rounded-lg tw-bg-green-500 tw-text-white';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
This is called inside addToBundle when the product has been added.
Conclusion
Creating a dynamic product bundling experience in BigCommerce is a great way to enhance customer satisfaction and boost sales. By leveraging Stencil, TailwindCSS, DaisyUI, and GraphQL, you can build a seamless and customizable bundling solution. Start experimenting with the provided code and tailor it to your store’s needs.