I have just written some updates docs on this topic - below the implementation part of it:
Bundles are products of the type BOM (bill of materials), also called parts lists - products which consist of other products. A bundle can be as simple as a fixed collection of products, like a bike sold as a frame, two wheels, and a handlebar, and as advanced as a fully configurable package where the customer picks products, variants, and quantities for each part of the bundle.
How bundles are created and priced in the administration is covered in the Manual article Bundles. This article covers the frontend implementation:
- Rendering a bundle in a product details template using viewmodels
- Adding a bundle to cart, from the simplest case to fully configured bundles
- Rendering bundle order lines in the cart
The BOM viewmodels
Bundles are rendered like any other product with the Product Catalog for ViewModel app, where the details template inherits ProductViewModel. Two properties on the model tell you that you are dealing with a bundle and how it is priced:
- Model.ProductType is ProductType.Bom for bundle products
- Model.PriceType holds the price type - Calculated, FixedPrice, or CalculatedWithBase
The contents of the bundle are not part of the model itself - they are loaded with the GetBomConfiguration() extension method from ProductViewModelExtensions, which returns a BomConfigurationViewModel:
|
Viewmodel
|
Purpose
|
|
BomConfigurationViewModel
|
The complete bundle configuration - Items holds the fixed items and Groups the configurable groups
|
|
BomItemViewModel
|
A fixed item with Id, Name, Quantity, and a Product reference
|
|
BomGroupViewModel
|
A configurable group with Id, Name, NoneSelectedText, DefaultProduct, and the Products the customer can choose between
|
|
ProductInfoViewModel
|
A lightweight product reference holding ProductId and VariantId
|
The product references are deliberately lightweight. When you need names, images, prices, or variant information for the products inside the bundle, expand a reference to a full ProductViewModel with the GetProduct() extension method from ProductInfoViewModelExtensions - or expand a whole list at once with GetProducts():
@using Dynamicweb.Rendering
@using Dynamicweb.Ecommerce.ProductCatalog
@inherits ViewModelTemplate<ProductViewModel>
@{
var bom = Model.GetBomConfiguration();
}
@foreach (var item in bom.Items)
{
var itemProduct = item.Product.GetProduct();
<p>@item.Quantity x @itemProduct?.Name (@itemProduct?.Price?.PriceFormatted)</p>
}
For a real-world reference implementation, see the Swift ProductBom template.
[!TIP] Product feeds do not include bundle contents automatically. To publish the elements of a bundle in a feed template, call GetBomConfiguration() on each bundle product and serialize the items and groups alongside the product data.
Adding a bundle to cart
A bundle is added to cart like any other product - by submitting the add cart command with a ProductID. The CartHandler receives the command and CartService builds one order line for the bundle itself plus one component order line for each bundle item.
What makes bundles special is the set of additional form fields which configure the bundle content. There are two groups of fields, and they are keyed differently:
|
Bundle item type
|
Field
|
Value
|
Behavior when omitted
|
|
Fixed item
|
{productId}Quantity
|
The quantity of the item per bundle
|
The quantity configured on the bundle item is used
|
|
Fixed item
|
{productId}VariantId
|
A variant combination id, e.g. VO51.VO87
|
The product is added without a variant
|
|
Group
|
{itemId}
|
The selected product id, or productId|variantId
|
The group's default product is used, or the first product
|
|
Group
|
{itemId}Quantity
|
The quantity of the selected product per bundle
|
Defaults to 1
|
Note the difference in how the fields are keyed:
- Fields for fixed items are prefixed with the product id of the item, e.g. PROD42Quantity
- Fields for groups are named after the bundle item id - the Id property on BomGroupViewModel, e.g. ITEM7 and ITEM7Quantity
A few rules apply across both field groups:
- All per-item quantities are multiplied with the Quantity submitted for the bundle itself - ordering 2 bundles where an item has quantity 3 results in a component line with quantity 6
- Submitting a quantity of 0 (or less) for an item excludes it from the purchase entirely - this is how a customer removes an optional part from the bundle
- {productId}VariantId is only applied when the bundle item points at the product in general - if the editor locked the item to a specific variant, the field is ignored
- A group field can hold several comma-separated values - submitting multiple products from the same group (e.g. via checkboxes) adds a component line for each
[!NOTE] Units cannot be selected per bundle item - the UnitID parameter on the add command applies to the bundle's own order line only. Also note that submitting an empty value for a group does not deselect it; the default product is added instead. To let the customer opt out of a group, submit 0 in the group's quantity field as shown in the scenarios below.
Scenarios
The scenarios below progress from the simplest bundle to a fully configurable one. They all assume a details template as shown above, with bom holding the result of Model.GetBomConfiguration().
A simple bundle
A bike sold as a fixed collection - a frame, two wheels, and a handlebar. Nothing is configurable, so the template just presents the contents and submits the bundle product itself:
<h1>@Model.Name</h1>
<p>@Model.Price?.PriceFormatted</p>
<ul>
@foreach (var item in bom.Items)
{
<li>@item.Quantity x @item.Name</li>
}
</ul>
<form method="post">
<input type="hidden" name="ProductID" value="@Model.Id" />
<input type="hidden" name="Quantity" value="1" />
<button type="submit" name="CartCmd" value="add">Add to cart</button>
</form>
The cart now contains a bundle line and four component lines - the wheel line with quantity 2, as configured on the bundle item.
Adjustable item quantities
To let the customer decide how many of each fixed item they want, add a quantity field per item inside the form. The field is named after the item's product id, and 0 removes the item from the purchase:
@foreach (var item in bom.Items)
{
<label>
@item.Name
<input type="number" name="@(item.Product.ProductId)Quantity" value="@item.Quantity" min="0" />
</label>
}
Items with variants
When a fixed item points at a product with variants - say the frame and the wheels both come in two sizes - the customer must be able to pick the variant. Expand the item product and render a variant selector named {productId}VariantId:
@foreach (var item in bom.Items)
{
var itemProduct = item.Product.GetProduct();
<label>
@item.Name
@if (string.IsNullOrEmpty(item.Product.VariantId) && itemProduct?.VariantInfo?.VariantInfo is not null)
{
<select name="@(item.Product.ProductId)VariantId">
@foreach (var option in itemProduct.VariantInfo.VariantInfo)
{
<option value="@option.OptionID">@option.OptionName</option>
}
</select>
}
<input type="number" name="@(item.Product.ProductId)Quantity" value="@item.Quantity" min="0" />
</label>
}
The string.IsNullOrEmpty(item.Product.VariantId) check matters: it distinguishes items where the editor locked a specific variant (nothing to select) from items pointing at a variant master (customer selects). For products with two variant dimensions, join the option ids with a period, e.g. VO51.VO87 - see the Swift template for a two-dimensional example.
Selecting a product from a group
A configurable slot - the customer chooses between several handlebars. Render the group as radio buttons (or a dropdown) named after the group id, with the default product preselected:
@foreach (var group in bom.Groups)
{
<fieldset>
<legend>@group.Name</legend>
@foreach (var option in group.Products)
{
var optionProduct = option.GetProduct();
var isDefault = group.DefaultProduct?.ProductId == option.ProductId;
<label>
<input type="radio" name="@group.Id" value="@option.ProductId" checked="@isDefault" />
@optionProduct?.Name (@optionProduct?.Price?.PriceFormatted)
</label>
}
</fieldset>
}
Optional groups
To make a group optional - an add-on the customer can skip - use the group's quantity field, since submitting 0 excludes the slot from the purchase. The NoneSelectedText configured on the bundle item is meant as the label for the opt-out choice:
@foreach (var group in bom.Groups)
{
<label>
@group.Name
<select name="@(group.Id)Quantity">
<option value="0">@group.NoneSelectedText</option>
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</label>
@* ...followed by the product selector for the group as shown above *@
}
Group products with variants
The most advanced case - a Mountain bike starter package where the customer picks a bike, a t-shirt, and a saddle, and each of those products comes in variants like color and size. The group field accepts the combined format productId|variantId, so the simplest robust pattern is a single dropdown per group listing every product and variant combination:
@foreach (var group in bom.Groups)
{
<label>
@group.Name
<select name="@group.Id">
@foreach (var option in group.Products)
{
var optionProduct = option.GetProduct();
@if (optionProduct?.VariantInfo?.VariantInfo is not null)
{
foreach (var variant in optionProduct.VariantInfo.VariantInfo)
{
<option value="@(option.ProductId)|@(variant.OptionID)">@optionProduct.Name - @variant.OptionName</option>
}
}
else
{
<option value="@option.ProductId">@optionProduct?.Name</option>
}
}
</select>
</label>
}
For a richer UI - product cards with a radio button per product and a separate variant dropdown which updates the radio value - see the Swift ProductBom template, which uses a small piece of JavaScript to write the productId|variantId value into the selected radio button.
Bundles in the cart
When a bundle is added to cart, the components are not separate lines in the main order line collection - they are attached to the bundle's order line. In the tag-based Shopping Cart templates this means the OrderLines loop contains one line per bundle, and the components are available in a nested loop called BOMItems where all the usual order line tags apply:
@foreach (LoopItem line in GetLoop("OrderLines"))
{
<div>
@line.GetValue("Ecom:Order:OrderLine.Quantity") x @line.GetValue("Ecom:Order:OrderLine.ProductName")
<strong>@line.GetValue("Ecom:Order:OrderLine.Price")</strong>
@foreach (LoopItem component in line.GetLoop("BOMItems"))
{
<div class="ps-4">
@component.GetValue("Ecom:Order:OrderLine.Quantity") x @component.GetValue("Ecom:Order:OrderLine.ProductName")
@component.GetValue("Ecom:Order:OrderLine.ProductVariantText")
</div>
}
</div>
}
A few things to be aware of when rendering bundle lines:
- Component quantities are totals - for 2 bundles each containing 2 wheels, the wheel component line has quantity 4
- The price on the bundle line follows the bundle's price type - for Fixed price bundles the component prices do not contribute to the line price
- Deleting the bundle order line - e.g. with the delorderline command - also removes its component lines
In contexts where order content is rendered from viewmodels, OrderLineViewModel exposes the same structure through the BomOrderLines property, and the GetProduct() extension method loads the full product for any of the lines.