The ability to buy different product variants like color, and size or to order several different variants (bulk order), by listing the product catalog, is simple and fast. It's no secret that such a function improves the user experience because it is convenient and fast, and therefore there is less chance that the buyer will close the tab. If we are talking about B2B stores, then this bulk order function becomes one of the main requirements when choosing an e-commerce platform.
In this post, I want to share my experience of how to build such functionality in an online store that works based on BigCommerce.
What you will need for this:
- Make sure that we have Stencil CLI installed. Installation instructions are here: https://developer.bigcommerce.com/docs/storefront/stencil/cli/install
- In my example, I will use the BigCommerce base theme Cornerstone, the current version can be downloaded from the link: https://github.com/bigcommerce/cornerstone, or by downloading it from your control panel. In this example, I use Cornerstone Version 6.12.0
In my example, I want to use only standard tools, and therefore I not use additional packages, the tools provided by Stencil CLI will be enough, it contains all the tools we will need, I use the Cornerstone theme because it is the base theme from which all custom themes begin.
Run the project locally, let's go!
One of the biggest problems of implementing such functionality is the transfer of product data to the listing page, in this case to the category page. The main purpose of this post is to show one method of doing this.
Let's prepare our project before we move on to the custom code. Changes will be added to three files.
templates\pages\category.html
templates\components\products\card.html
assets\js\theme\category.js
Get data about the product, namely the list of options and other data that we will need, on the categories page I will use GraphQL, so first we need to transfer the data about the api token. For this we use Handlebars Helpers inject: https://developer.bigcommerce.com/docs/storefront/stencil/themes/context/handlebars-reference#inject
The first step in templates/pages/category.html put this part after global attributes:
After putting this line in the file must look like:
The use of attributes is a convenient tool for obtaining data for processing in custom solutions. In this case, we use the date attribute to transfer translations from the category "product"
<div class="lang-translation" data-lang-translation=""></div>
I create a block, and in the data attribute I add translations.
Second step templates\components\products\card.html we need to add a place where our variant option block will be dynamically inserted. We need the date attribute to identify the product on the category page, we use handlebars to pass the product id.
Third step in assets\js\theme\category.js we create a new method in the category class. This method get all elements of the product cards and identifies the ids of the products and returns an array with all ids on the page.
So, now, we have completed the preparatory work.
To get product data, in our case options, I will use Storefront GraphQL API Reference https://developer.bigcommerce.com/graphql-storefront/reference#definition-ProductOptionValue.
You can find out more about GraphQL in BigCommerce here: https://developer.bigcommerce.com/docs/graphql-storefront
So, we will need to create several js files, I suggest creating a custom folder in assets\js\theme. A category folder is created in the custom folder, and our js file is created in it: optionProductData.js
key, productIdArray - we will receive this parameter from assets\js\theme\category.js
Query should look like this:
The structure of the received data is quite deep and for more convenient use it will be very convenient to go through the data and record it in a more convenient form, and also, to filter a group of options by name.
const products = response.data.site.products.edges;
products.forEach(product => {
const indexOfInhoud = product.node.variants.edges[0].node.options.edges.findIndex(
(edge) => edge.node.displayName.toLowerCase() === 'size',
);
if (indexOfInhoud < 0) return;
const variants = product.node.variants.edges;
const mappedVariants = variants.map((variant) => {
if (!variant.node) return;
const label = variant.node.options.edges[indexOfInhoud].node.values.edges[0].node.label;
const options = variant.node.options;
return {
prices: variant.node.prices,
defaultImage: variant.node.defaultImage,
inventory: variant.node.inventory.isInStock,
label,
sku: variant.node.sku,
options,
};
});
const obj = {
id: product.node.entityId,
productName: product.node.name,
productVariant: mappedVariants
}
allProductVariants.push(obj);
})
Now that everything is together, our file should look something like this:
export default function (key, productIdArray) {
const allProductVariants = [];
fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`
},
body: JSON.stringify({
query:`query productById {
site {
products(entityIds: [${String(productIdArray)}]) {
edges {
node {
name
sku
entityId
variants {
edges {
node {
defaultImage {
urlOriginal
}
sku
entityId
inventory {
isInStock
}
options {
edges {
node {
displayName
entityId
values {
edges {
node {
entityId
label
}
}
}
}
}
}
prices {
price {
value
formatted
}
salePrice {
value
formatted
}
basePrice {
value
formatted
}
}
}
}
}
}
}
}
}
}
`
}),
})
.then(res => res.json())
.then(function(response) {
if(response) {
const products = response.data.site.products.edges;
products.forEach(product => {
const indexOfInhoud = product.node.variants.edges[0].node.options.edges.findIndex(
(edge) => edge.node.displayName.toLowerCase() === 'size',
);
if (indexOfInhoud < 0) return;
const variants = product.node.variants.edges;
const mappedVariants = variants.map((variant) => {
if (!variant.node) return;
const label = variant.node.options.edges[indexOfInhoud].node.values.edges[0].node.label;
const options = variant.node.options;
return {
prices: variant.node.prices,
defaultImage: variant.node.defaultImage,
inventory: variant.node.inventory.isInStock,
label,
sku: variant.node.sku,
options,
};
});
const obj = {
id: product.node.entityId,
productName: product.node.name,
productVariant: mappedVariants
}
allProductVariants.push(obj);
})
}
return allProductVariants;
})
.then(data => {
console.log('[data]: ', data);
});
}
Great, now we can import this function into the category.js file and call it by passing parameters.
import optionProductData from './custom/category/optionProductData';
and call section onReady()
optionProductData(this.context.apiToken, this.dataProductCollection());
and in initFacetedSearch() - This is required to update data when using pagination or filters
And if we have a created product, we will get the following data with the "Size" options.
As a result of the console, we should get the following data. This data is enough to build a diverse interface. Having a sku, we can create a link to add the product to the cart using BigCommerce Add to Cart URLs https://developer.bigcommerce.com/docs/storefront/cart-checkout/guide/add-to-cart-urls
For an example of how to use this data, I will create a simple interface. I won't go into detail about exactly what I did since it's vanilla js and creating dynamic elements. Let's dwell only on specific points that are the features of BigCommerce.
The first point is access to translations. So that our interface is multilingual. For this we already created a block in templates/pages/category.html:
<div class="lang-translation" data-lang-translation=""></div>
Example:
const getTranslations = () => {
const langTranslation = document.querySelector('.lang-translation');
if (!langTranslation) {
return;
}
const lang = langTranslation.dataset.langTranslation || '';
return JSON.parse(lang).translations;
};
const lang = getTranslations();
Now we have access to translations by key. To see what we have in translations or to add new ones, we can look in the folder: lang.
Learn more: https://developer.bigcommerce.com/docs/storefront/stencil/themes/localization/translation-keys
Second, we need an additional function that will dynamically update the products in the preview cart. Because adding to the cart will happen without reloading the page. For this, I will use BigCommerce storefront api: https://developer.bigcommerce.com/docs/rest-storefront, namely Get a Cart: https://developer.bigcommerce.com/docs/rest-storefront/carts#get-a-cart
To do this, we will create a global folder in our custom folder and create cartUpdate.js in it
export default function () {
const pricePlace = document.querySelector('[data-cart-preview] .countPill')
fetch('/api/storefront/cart', {
credentials: 'include',
}).then((response) => {
return response.json();
}).then((myJson) => {
if (myJson.length > 0) {
const products = myJson[0].lineItems.physicalItems;
let quantProd = 0;
products.forEach(element => {
quantProd += element.quantity;
});
pricePlace.innerHTML = `${quantProd}`;
pricePlace.style.display = 'inline-block';
}
});
};
To do this, we will create a function that will be dynamically created based on the received data and placed in the corresponding product based on the product id. Let's create another js file optionCard.js in our custom/category directory. Ok, now we are ready to create a simple interface for the bulk order block.
Example for optionCard.js:
import cartUpdate from '../global/cartUpdate';
export default function ( data ) {
const getTranslations = () => {
const langTranslation = document.querySelector('.lang-translation');
if (!langTranslation) {
return;
}
const lang = langTranslation.dataset.langTranslation || '';
return JSON.parse(lang).translations;
};
const lang = getTranslations();
if (data.length === 0) return;
const bulkOrderField = document.querySelectorAll('#product-listing-container .product .option-order-box');
bulkOrderField.forEach(item => {
data.forEach(elem => {
if (Number(item.dataset.productId) === elem.id) {
let shablon = ``;
const variants = elem.productVariant;
variants.forEach(variant => {
const line = `
<div class='bulk-option-line' data-sku=${variant.sku}>
<div class='bulk-option-group'>
<input checkbox type='checkbox' class='checker' />
<p>${variant.label}</p>
</div>
<p class='price-block'>
${variant.prices.salePrice ? ('<span class="sell-price">' + variant.prices.salePrice.formatted +'</span><br />') : ''}
<span class=${variant.prices.salePrice && 'text-through'}>${variant.prices.basePrice.formatted}</span>
</p>
<div class='product-marker'>
<span aria-label='status' class='status-marker ${variant.inventory ? 'success-marker' : 'warning-marker'}'></span>
</div>
<input class='product-qty' min='0' type='number' placeholder='0' />
</div>
`;
shablon += line;
})
item.innerHTML = `
<div class='bulk-wraper'>
<div class='bulk-wrap-line title-line'>
<div>${lang['products.options']}</div>
<div></div>
<div>${lang['products.quantity']}</div>
</div>
<div class='bulk-option-wrap'>
${shablon}
</div>
<button class='button bulk-buy-button'>${lang['products.add_to_cart']}</button>
<div class='error-msg'>
<span style='display: none;'>${lang['products.select_one']}</span>
</div>
</div>
`;
item.classList.add('bulk-add');
}
})
});
const checkBulk = document.querySelectorAll('.checker');
checkBulk.forEach(check => {
check.onchange = () => {
const element = check.parentElement.parentElement;
const qty = element.querySelector('.product-qty');
if (check.checked) {
qty.value = 1;
} else {
qty.value = 0;
}
}
});
const productQty = document.querySelectorAll('.product-qty');
productQty.forEach(qty => {
qty.onchange = () => {
const element = qty.parentElement;
const checker = element.querySelector('.checker');
if (qty.value > 0) {
checker.checked = true;
} else {
checker.checked = false;
}
}
})
const bulkBuyButton = document.querySelectorAll('.bulk-buy-button');
bulkBuyButton.forEach(buy => {
buy.onclick = async () => {
const orderProduct = [];
const element = buy.parentElement;
const errorMsg = element.querySelector('.error-msg span')
const qtyLine = element.querySelectorAll('.product-qty');
const skuLine = element.querySelectorAll('.bulk-option-line');
const chackBulk = element.querySelectorAll('.checker');
for (let i = 0; i<qtyLine.length; i++){
if (qtyLine[i].value > 0) {
orderProduct.push(`/cart.php?action=add&sku=${skuLine[i].dataset.sku}&qty=${qtyLine[i].value}`);
}
};
if (orderProduct.length > 0) {
errorMsg.style.display = 'none';
buy.innerText = lang['products.adding_to_cart'];
buy.disabled = true;
for (const link of orderProduct) {
const res = await fetch(link);
}
qtyLine.forEach(line => {
line.value = 0;
});
chackBulk.forEach(item => {
item.checked = false;
});
buy.disabled = false;
cartUpdate();
buy.innerText = `${lang['products.add_to_cart']}`;
} else {
errorMsg.style.display = 'block';
const remover = () => {
errorMsg.style.display = 'none';
};
setTimeout(remover, 4000);
}
}
})
}
The last step is to add some styling to make our block look good.
$black: #000000;
$white: #ffffff;
$gray: #555555;
$warning: red;
$success: #82B456;
.option-order-box {
.error-msg {
span {
color: $warning;
}
}
.bulk-wraper {
padding: 15px;
background-color: $white;
position: relative;
border: 1px solid $gray;
border-radius: 4px;
.title-line {
display: flex;
justify-content: space-between;
gap: 14px;
}
.bulk-buy-button {
display: flex;
justify-content: center;
width: 100%;
border-color: $black;
color: $black;
margin-bottom: 0;
}
}
.bulk-option-wrap {
.bulk-option-line {
display: flex;
justify-content: space-between;
gap: 15px;
align-items: center;
margin-bottom: 10px;
.bulk-option-group {
display: flex;
gap: 7px;
flex: 2;
}
.product-qty {
height: 24px;
width: 50px;
color: $black;
text-align: right;
border-color: $black;
border-radius: 0;
border-width: 1px;
&::placeholder {
color: $black;
}
}
p {
margin-bottom: 0;
flex: 2;
line-height: 1;
}
.price-block {
white-space: nowrap;
text-align: right;
}
.text-through {
text-decoration: line-through;
}
.checker {
-moz-appearance: none;
-webkit-appearance: none;
-o-appearance: none;
outline: none;
content: none;
width: 15px;
height: 15px;
&::before {
content: "\2713";
font-weight: 700;
font-size: 10px;
color: transparent;
background: $white;
display: block;
width: 15px;
height: 15px;
border: 1px solid $gray;
display: flex;
justify-content: center;
align-items: center;
}
&:checked:before {
color: $black;
}
}
.product-marker {
.status-marker {
height: 10px;
width: 10px;
border-radius: 50%;
display: block;
}
.warning-marker{
background-color: $warning;
}
.success-marker {
background-color: $success;
}
}
}
}
}
And to tie it all together, optionCard.js import in optionProductData.js
import optionCard from './optionCard' and call.
Result in category page.
This is only one implementation option, the data can be used for different tasks, as well as for different listing pages. Having an effective way of adding data, you can build interfaces of different complexity.
At Blackbit, we always try to implement all the additional and not provided by standard themes functionality that is necessary for effective sales and improvement of user experience.