Introduction
IT’S ALIVE! Cried Dr. Frankenstein as his monster came to life. A monster composite monster comprised of a number of parts from many different people. A Halloween classic, and surprisingly a relatively common business need, and one we have been asked to build on multiple occasions in Shopify. A type of product we refer to as a “build a box”. It’s exactly what it sounds like, a composite product that is comprised of a number of other existing individual products. Like a Frankenstein product!
Here’s a delicious example, you might have a client that sells boxes of chocolates, and each box can be mixed and matched to form a set number of flavors/varieties of chocolates, so that the customer can select exactly what they want and effectively “build” their box before adding it to the cart and purchasing it.
Here’s the catch, Shopify does not provide any immediately obvious way to construct such a… wait for it... “out of the box” product. You can, of course, create a master product and assign numerous variants within that for variations of particular properties such as size or color, but there is no native way to assign what we need here: essentially a parent-child relationship between the master product and each of its component flavor products.
This has been discussed in several threads in the Shopify forum, and the general advice within said threads is to use a third-party app such as the Bold Product Builder. While that is a perfectly valid approach to solving this problem, we wanted to see if we could find a way to do this via creative usage of the Shopify core only. This way it can be built from the ground up to only contain exactly the specific features we need.
Variants Are the Key
As we touched on above, Shopify’s core functionality gets us halfway there with product variants. We found a way to leverage this system into a full “build a box” product mechanism. Here’s how it works…
To use the example of a store that sells boxes of chocolates, we need to somehow create a master product for the box, and within that also have individual products for each chocolate flavor. Our approach was to create a product which we’ll call “Box of Chocolates”. Within that, we move on to the key to this whole solution: variants.
We add the variants based around their flavor values, and we also add the master box itself as the first variant (we will see the reason for this next). Each chocolate flavor is added in the typical way, with the inventory price, SKU etc. The box variant does not need any of these attributes, simply the inventory as it relates to the particular store’s inventory for the particular box product.
On to the Code
Next, we want to create a dedicated product template for this.
First, we need to create a number of placeholder slots on the page so the user can choose the flavors they want to go into the box. In this example we want 8 slots.
<div id="box" data-variant-id="{{ boxVariantId }}" data-count="0"><div class="grid"></div>
<div class="grid"></div>
<div class="grid"></div>
<div class="grid"></div>
<div class="grid"></div>
<div class="grid"></div>
<div class="grid"></div>
<div class="grid"></div>
</div>
Each slot will be populated when a flavor is clicked. We add two data attributes to the parent div here, one to store the variant ID of the master box, and another to keep track of how many slots are filled at any given time (the javascript that follows below handles this).
Next, we need to add a grid containing the flavors available to choose from. In our custom product template, we output this by looping through the variants and generating the grid.
<div id="flavors">
{% for variant in product.variants %}
{% if variant.title != "Box" %}
<a data-variant-id="{{ variant.id }}" data-variant-title="{{ variant.title }}" href="#">
<img src="{{ variant.image.src | img_url: '190x' }}" alt="{{ variant.title }}">
<div class="title">{{ variant.title }}</div>
<div class="price">{{ variant.price | money }}</div>
<div class="weight">{{ variant.weight | weight_with_unit: variant.weight_unit }}</div>
</a>
{% endif %}
{% endfor %}
</div>
The structure here is a link containing the variant thumbnail, title, price and weight. You can obviously adjust the information here as needed, this is just what we wanted for our particular store. Note that we add two data attributes to the link, which will be used in the javascript function in the next step. Also, note that we deliberately omit the master “Box” variant so that it doesn’t appear in the grid. This is used only to track the inventory of boxes ordered.
Now, we need to write some javascript to handle the click event of the flavors, so that when a flavor is selected it is added to a slot in the box.
$('#flavors a').click(function(e) {
var boxItem = $('#box').find('.flavor.empty:first').append($(this).html());
$(boxItem).attr({'data-variant-id': $(this).attr('data-variant-id'), 'data-variant-title': $(this).attr('data-variant-title')});
$('<a class="remove" href="#"><i class="fa fa-times"></i></a>').insertAfter('.flavor.empty:first');
$(boxItem).removeClass('empty');
$('#box').attr('data-count', parseInt($('#box').attr('data-count')) + 1);
$('#selection-counter').html($('#box').attr('data-count') + ' of ' + $('#box-sizes .selected a').attr('data-quantity') + ' selected');
$('.product__price .money').html('$' + (parseCurrency($('.product__price .money').html().replace("$", "")) + parseCurrency($(this).children('.price').html().replace("$", ""))).toFixed(2));
e.preventDefault();
});
The approach here is to find the first empty slot available then populate it with the data pertinent to the flavor the user has just selected. We assign the slot the data-variant-id and data-variant-title values, and insert a way to remove it from the box with the <a class="remove"> . We also increment the data-count value on the box. An optional feature, but one we decided to add, is to add a counter below the box to show how many slots have been filled at any given time, which is also updated here with this line:
$('#selection-counter').html($('#box').attr('data-count') + ' of ' + $('#box-sizes .selected a').attr('data-quantity') + ' selected');
Finally, we update the total product price (so that the user can see the cost of the total box based on the flavors within it) with:
$('.product__price .money').html('$' + (parseCurrency($('.product__price .money').html().replace("$", "")) + parseCurrency($(this).children('.price').html().replace("$", ""))).toFixed(2));
The final thing we need in this product template is the mechanism to add everything to the cart correctly. We don’t need to modify the standard add to cart form, whichever theme you use will have a default form, and this is fine to use because we then add a custom javascript function to handle the add to cart event. This is, after all, a bespoke product setup and we need to process it accordingly. In order to do that, we need to find our theme’s existing add to cart javascript function and amend it so that it processes our “Build a Box” product in the specific way we need.
The approach we want is to use the Shopify API’s /cart/add.js call to add both the master box variant and the individual flavor variants to the cart when the user clicks the standard add to cart button. We add some code to run when the add to cart form is submitted (note that this only runs on the add to cart form on this custom template because we target the specific form class buildabox, which we added to the form on this page only).
$('#AddToCartForm.buildabox').submit(function() {
if($('#box-quantity').val() <= 99) { // Shopify limits each add to cart operation to 100 variants so we need to ensure the user doesn't exceed that
if($('#box .flavor.empty').length === 0) { // Only process once all flavors are chosen
// Set necessary variables for later use
var boxQuantity = $('#box-quantity').val(); // The chosen box size
var variantsToAdd = {}; // A json array that holds the titles of the flavor variants in the box
var boxChoices = []; // An array that holds the overall box choices containing both title and quantity
var boxPrice = 0; // Used to calculate the final price for the box a each flavor variant is added up
// Next we generate the master box product's line item string, which contains the flavors and quantities of each, i.e. "1 x flavor 1, 3 x flavor 2" etc.
for (i = 0; i < boxQuantity; i++) { // Loop through quantity of box
$("#box .flavor").each(function() { // Loop through each flavor in box
var flavor = $(this).attr('data-variant-title'); // Set flavor title
var flavorId = parseInt($(this).attr('data-variant-id')); // Set flavor variant id
boxPrice = boxPrice + parseFloat($(this).find('.price').html().replace("$", "")); // Add flavor price to box total
if(variantsToAdd[flavorId] != null) { // If this flavor is already in the cart then we increment its quantity by the amount chosen in this box
variantsToAdd[flavorId] = parseInt(variantsToAdd[flavorId]) + 1; // Increment quantity
boxChoices[flavor] = boxChoices[flavor] + 1;
$.each(boxChoices, function (i, value) {
if(boxChoices[i].title == flavor) {
boxChoices[i].quantity = boxChoices[i].quantity + 1;
}
});
}
else { // Otherwise if the flavor is not already in the cart then we add only the amount in this box to the data object
variantsToAdd[flavorId] = 1;
boxChoices[flavor] = 1;
boxChoices.push({
id: flavorId,
title: flavor,
quantity: 1
});
}
});
}
var flavorString = ''; // Define empty string to add line item properties to
// Loop through boxChoices to get the combined flavor quantities and titles and append to the flavorString
$.each(boxChoices, function (i, value) {
boxChoices[i].quantity = boxChoices[i].quantity / boxQuantity; // If multiple boxes (i.e. quantity > 1) are being added then we need to divide the flavor quantities added to boxChoices above by the box quantity so that they are correct
flavorString = flavorString + boxChoices[i].quantity + ' x ' + boxChoices[i].title + ', ';
});
// Remove the trailing comma from the line item string
flavorString = flavorString.replace(/,\s*$/, "");
// Now that the data is ready to go we use the Shopify.moveAlong function to add the master box and flavor variants to the cart
Shopify.queue = []; // Define the empty queue
// Push the master box to the queue
Shopify.queue.push({
variantId: $('#box').attr('data-variant-id'),
quantity: boxQuantity,
properties: {
Size: boxQuantity,
Flavors: flavorString,
Price: '$' + formatCartPrice(boxPrice)
}
});
// Loop through the boxChoices array and push flavor variants to queue
$(boxChoices).each(function(i, element) {
Shopify.queue.push({
variantId: element.id,
quantity: element.quantity * boxQuantity // The flavor quantity needs to be multiplied by box quantity to get total value
});
});
Shopify.moveAlong = function() {
// If we still have requests in the queue, let's process the next one.
if (Shopify.queue.length) {
var request = Shopify.queue.shift();
var data = {};
if(request.properties) {
data = {
id: request.variantId,
quantity: request.quantity,
properties: {
Size: $('#box-sizes .selected a').attr('data-quantity'),
Flavors: flavorString,
Price: '$' + formatCartPrice(boxPrice)
}
};
}
else {
data = {
id: request.variantId,
quantity: request.quantity
};
}
$.ajax({
type: 'POST',
url: '/cart/add.js',
dataType: 'json',
data: data,
success: function(response) {
Shopify.moveAlong();
},
error: function(response) {
alert(response.responseJSON.description); // Show error in dialog
// if it's not last one Move Along else update the cart number with the current quantity
if (Shopify.queue.length){
Shopify.moveAlong();
}
}
});
}
};
Shopify.moveAlong();
}
else { // If the user has not selected enough flavors to fill the box size chosen then we display a warning instead of processing it
alert('Please select the remaining items in your box before adding it to the cart.');
}
}
});
We also need to add a function to remove flavors from the box too.
var parentLineNumber = parseInt($(e.currentTarget).attr("data-line-number")); // Get the cart line number of the parent box that is being removed from its data attribute
var boxProductsArray = $(e.currentTarget).attr("data-line-items").split(','); // Get the cart line items of the parent box that is being removed from its data attribute
var flavorsToRemove = []; // Define an array for later use to hold the titles and quantities of the child flavor variants to be removed
var jsonString = {}; // Define a json string to be used to pass the data in the API call
var newProductQuantities = []; // Define an array to be used to hold the IDs and quantities of the existing flavor variants in the cart after those in the current box have been removed
var boxQuantity = parseInt($(e.currentTarget).attr("data-box-quantity")); // Get the box quantity in case the user is removing one with a multiple quantity
// Loop through the flavors extracted from the parent box's line items
$.each(boxProductsArray, function(index, element) {
// For each item process the string and convert it into title and quantity data that is added to the flavorsToRemove array
var productChunks = element.split(' x ');
var productQuantity = $.trim(productChunks[0]);
var productTitle = $.trim(productChunks[1].replace("&", "&"));
flavorsToRemove.push({
title : productTitle,
quantity : productQuantity * boxQuantity // To get the true quantity to remove we need to multiply the quantity of the flavor in the box with the quantity of the box itself
});
});
// Loop through the cart and for any flavor variants that have a greater quantity than that which we are removing (i.e. they belong to other boxes) then subtract only the quantity in our box from them
jQuery.getJSON('/cart.js', function(thisCart, textStatus) { // Get cart items
$.each(thisCart.items, function(cartIndex, cartElement) { // Loop through cart items
$.each(flavorsToRemove, function(productsToRemoveIndex, productsToRemoveElement) { // Loop flavorsToRemove array
if(cartIndex == parentLineNumber) { // If the loop is at the line number of the master box product
newProductQuantities[cartIndex] = 0; // Set the quantity to 0 since the master box is being removed
}
else { // Else we must be on a line item for a flavor variant
var cartProductTitle = cartElement.title.replace('Build a Box - ',''); // Remove the unwanted prefix from the flavor variant title, so we can accurately match it
if(cartProductTitle == productsToRemoveElement.title) { // If the title of the cart line item product matches the current one in the flavorsToRemove array then we have a hit
newProductQuantities[cartIndex] = (cartElement.quantity - productsToRemoveElement.quantity); // Deduct the quantity of the flavor from the box that is being removed
return false;
}
else { // Else the flavor is one that is not in the box we are removing, so leave the quantity the same
newProductQuantities[cartIndex] = cartElement.quantity;
}
}
});
});
jsonString['updates'] = newProductQuantities; // Now that the newProductQuantities array is built, assign it to the json string ready to be passed to the API call
// Finally we make the API call to update the cart with the new quantities we set above
$.ajax({
url: '/cart/update.js',
type: 'POST',
dataType: 'json',
data: jsonString,
success: function (data) {
_doUpdateDropdownCart(data, true); // On success we call a generic function to refresh the cart drawer data displayed
},
error: function(e) {
console.log(e);
}
});
});
The final piece that we need to consider is the cart. Since we are adding a number of variants individually to the cart, by default, they would display individually in the cart and it wouldn’t be immediately obvious that they belong to a “Build a Box” product. In order to make our new custom product display nicely in the cart, we need to open our cart.liquid template and make the following amendments.
<ul class="cart-list">
{% for item in cart.items %}
{% if item.product_id == 1392027172913 %}
{% if item.id == 12485367005233 %}
<li class="cart-list__item">
<div class="cart-list__item--img clearfix">
<div class="img-holder img-holder--circle img-holder--circle-sm">
<a href="{{ item.product.url }}">
<img src="{% if item.product.images.size > 0 %}{{ item.product.featured_image.src | product_img_url: 'small' }}{% else %}{{ 'img_no_image.jpg' | asset_url }}{% endif %}" alt="{{ item.title | escape }}">
</a>
</div>
</div>
<div class="cart-list__item--title">
{% for property in item.properties %}
{% if property.first == 'Size' %}
{% assign boxSize = property.last %}
{% endif %}
{% endfor %}
<a href="{{ item.product.url | within: collection.all }}">{{ item.title | replace: ' - Box', '' | truncate: 20 }} - {{ boxSize }}pc</a>
<div class="box-flavors">
{% for property in item.properties %}
{% if property.first == 'Flavors' %}
{% assign flavorString = property.last %}
{% elsif property.first == 'Price' %}
{% assign boxPrice = property.last %}
{% endif %}
{% endfor %}
{% assign flavorArray = flavorString | split: "," %}
{% for flavor in flavorArray %}
<div class="flavor">{{ flavor | strip }}</div>
{% endfor %}
</div>
</div>
<div class="cart-list__item--quantity">
<div class="quantity-text">{{ item.quantity }}</div>
</div>
<div class="cart-list__item--price">
<span class="money">{{ boxPrice }}</span>
</div>
<div class="cart-list__item--remove">
<a class="remove-box" href="#" data-line-number="{{ forloop.index }}" data-product-id="{{ item.variant_id }}" data-line-items="{{ flavorString }}" data-box-quantity="{{ item.quantity }}">
<span class="icon icon--delete"></span>
</a>
</div>
</li>
{% endif %}
{% else %}
<li class="cart-list__item">
<div class="cart-list__item--img clearfix">
<div class="img-holder img-holder--circle img-holder--circle-sm">
<a href="{{ item.product.url }}">
<img src="{% if item.product.images.size > 0 %}{{ item.product.featured_image.src | product_img_url: 'small' }}{% else %}{{ 'img_no_image.jpg' | asset_url }}{% endif %}" alt="{{ item.title | escape }}">
</a>
</div>
</div>
<div class="cart-list__item--title">
<a href="{{ item.product.url | within: collection.all }}">{{ item.title | truncate: 20 }}</a>
</div>
<div class="cart-list__item--quantity">
<div class="quantity-field">
<span class="icon icon--arrow-up js-up-quantity"></span>
<input id="updates_{{ item.id }}" type="number" name="updates[]" min="1" value="{{ item.quantity }}" size="5" {{ bold_qty_attr }}>
<span class="icon icon--arrow-down js-down-quantity"></span>
</div>
</div>
<div class="cart-list__item--price">
<span class="money">{{ item.price | money }}</span>
</div>
<div class="cart-list__item--remove">
<a href="/cart/change?line={{ forloop.index }}&quantity=0">
<span class="icon icon--delete"></span>
</a>
</div>
</li>
{% endif %}
{% endfor %}
</ul>
First of all, in the standard loop, we check for the ID of our master box product (1392027172913 in this case). Then we further check for the variant ID of our box variant (12485367005233 in this case). We then output the line for our master box product. From here we want to then list the flavors and quantities of each, so we add the further loop inside <div class="box-flavors"> to do this.
Likewise, if your store runs on Shopify Plus, you can apply the same formatting approach to the checkout template.
The Finished Product
Now we have the product template done, we can see the final page with the available slots:
Plus the grid of the available flavors to choose from:
When we build the box and add it to the cart we can also see the custom formatting we added here too:
So whether or not you believe that you could build your very own Frankenstein monster is beside the point, what we do know is that you can now build your very own Frankenstein product in Shopify, and allow the users to choose which parts go into it. I would say this is way ahead of Dr. Frankenstein and his ability, who knows… maybe someone will write a book about you!
For more on creating a Build A Box product in Shopify, see our sequel here: "Product in Shopify - Part 2: How to remove the limit on flavors in the box"