Skip to content
msBundles
Product bundles for miniShop3 with shared pricing, discounts, and cart sync
  1. Extras
  2. msBundles
  3. Storefront
  4. Frontend

Frontend

Snippet calls, chunk placeholders, CSS/JS, and how a bundle looks in the cart. Snippet parameters are also covered under Snippets.

Before diving into chunks, check two things:

  1. <head> includes msBundles.initialize. Otherwise add buttons and styles will not work.
  2. msCart has selector. Otherwise you get a toast after add, and the on-page cart block stays stale.

How the card looks

Card: Desk set

Card: Travel set

Card: qty=2

Card: Kitchen (fixed + optional)

Ready-made product page

fenom
{extends 'file:templates/base.tpl'}

{block 'head'}
  {'!msBundles.initialize' | snippet}
{/block}

{block 'content'}
  <h1>{$_modx->resource.pagetitle}</h1>

  {'!msBundles' | snippet : [
    'product' => $_modx->resource.id,
    'tpl' => 'tplMsBundlesItem',
    'wrapperTpl' => 'tplMsBundlesList',
    'emptyTpl' => 'tplMsBundlesEmpty',
    'activeOnly' => true,
    'quantity' => 1
  ]}

  <section id="ms-cart" data-ms-cart aria-live="polite">
    {'!msCart' | snippet : [
      'tpl' => 'tpl.msCart',
      'return' => 'tpl',
      'selector' => '#ms-cart'
    ]}
  </section>
{/block}
modx
[[!msBundles.initialize]]

[[!msBundles?
  &product=`[[*id]]`
  &tpl=`tplMsBundlesItem`
  &wrapperTpl=`tplMsBundlesList`
  &emptyTpl=`tplMsBundlesEmpty`
  &activeOnly=`1`
  &quantity=`1`
]]

<div id="ms-cart" data-ms-cart aria-live="polite">
  [[!msCart?
    &tpl=`tpl.msCart`
    &return=`tpl`
    &selector=`#ms-cart`
  ]]
</div>

In tpl.msCart, call tplMsBundlesCartInfo under the product name (see below).

Bundle selection logic

  1. bundle > 0 — one bundle by ID. If inactive and activeOnly=1, empty result.
  2. Else product > 0 — bundles that include the product.
  3. If nothing found or both are 0emptyTpl.

With msbundles_stock_behavior=hide and unavailable stock, the card is omitted. block and message keep the card with --blocked / --warning modifiers.

Theme (CSS variables)

Override tokens on .msbundles (or :root) so cards match the store palette:

css
.msbundles {
  --msbundles-color-accent: #your-brand;
  --msbundles-color-accent-hover: #your-brand-dark;
  --msbundles-color-text: #111827;
  --msbundles-color-muted: #6b7280;
  --msbundles-color-border: #e5e7eb;
  --msbundles-color-surface: #fff;
  --msbundles-radius: 0.5rem;
}
VariablePurpose
--msbundles-color-accentAccent: product links, Add button, cart badges
--msbundles-color-accent-hoverHover / active for accent buttons and links
--msbundles-color-textPrimary text on the card and composition
--msbundles-color-mutedSecondary text: description, SKU, captions
--msbundles-color-borderBorders for the card, composition rows, inputs
--msbundles-color-surfaceBundle card background
--msbundles-radiusCorner radius for the card, inputs, and buttons

More tokens in msbundles.css: spacing (--msbundles-space-*), fonts (--msbundles-font-*), savings/free (--msbundles-color-savings, --msbundles-color-free*), warning/error (--msbundles-color-warning*, --msbundles-color-danger*), shadow and focus-ring.

Card placeholders (tplMsBundlesItem)

PlaceholderDescription
idBundle ID
nameName
descriptionDescription
image, image_urlImage URL
image_html<div class="msbundles__media">…</div> block or empty
has_image0 / 1 — image present
active0 / 1 — active
quantityRequested number of bundles
item_countSum of product units in the set
product_countNumber of lines
productsComposition rows HTML (productTpl)
composition_html“Composition” section or empty
price_htmlPrice, strikethrough, savings. At 0 — “Free”
totalTotal (number)
original_totalSum at original prices
savingsSavings (number)
total_formattedTotal with currency or “Free”
original_total_formattedOriginal with currency
savings_formattedSavings with currency
available0 / 1 — can order
stock_messageStock error or warning
state_modifiermsbundles__item--available / --warning / --blocked
stock_behaviorblock, message, hide
max_bundle_quantityQty field limit

tplMsBundlesList: items — card HTML. With no bundle image and imageFallback=1, the card uses the first product thumb.

Composition line placeholders (tplMsBundlesProduct)

The stock chunk uses {$product_id}. In a custom chunk you can mirror catalog-style attributes with id = product_id.

PlaceholderDescription
product_idProduct ID
nameName
name_htmlName in a link or plain text
articleSKU
urlProduct URL
image, image_urlThumb or image
image_html<span class="msbundles__product-media">…</span> block or empty
quantityUnits of this product in one bundle
bundle_quantityRequested number of bundles
line_quantityquantity × bundle_quantity
required0 / 1 — required line
required_label“Required” / “Optional”
price_modeoriginal, fixed, discount_percent, discount_amount, free
is_free0 / 1 — line price is 0
unit_priceUnit price after the price mode
unit_price_formattedPrice with currency or “Free”
unit_price_html<span class="msbundles__price">…</span>
original_unit_priceOriginal unit price
original_unit_price_formattedOriginal with currency
original_price_htmlStrikethrough price or empty
line_totalLine total
line_total_formattedTotal with currency or “Free”

Custom card chunk

Classes (msbundles__*) are for styling only. JS finds nodes by data attributes.

fenom
<article
  data-msbundles="item"
  data-ms-bundle="{$id}"
  data-ms-bundle-available="{$available}"
>
  <h3>{$name}</h3>
  <div data-msbundles="summary">
    <p data-msbundles="price">{$price_html}</p>
    <span data-msbundles="product-count">{$product_count}</span>
  </div>
  <p data-msbundles="stock" role="status">{$stock_message}</p>
  <div data-msbundles="controls">
    <button type="button" data-msbundles-action="qty-dec" data-ms-bundle="{$id}">−</button>
    <input type="number" data-bundle-quantity data-ms-bundle="{$id}" value="{$quantity}" min="1" />
    <button type="button" data-msbundles-action="qty-inc" data-ms-bundle="{$id}">+</button>
    <button type="button" data-msbundles-action="add-bundle" data-ms-bundle="{$id}">
      {'msbundles.add_bundle' | lexicon}
    </button>
  </div>
</article>

Changing qty calls calculate: [data-msbundles="price"] gets price.price_html, stock status goes to [data-msbundles="stock"].

Storefront data attributes (JS contract)

AttributeWherePurpose
data-msbundles="list"list wrapperBundle list root
data-msbundles="item"cardCard root
data-ms-bundlecard / controlsBundle ID
data-ms-bundle-availablecard1 / 0 after calculate
data-ms-bundle-stockcardavailable / warning / blocked
data-msbundles="summary"summary blockAnchor for stock message
data-msbundles="price"price lineHTML replaced after calculate
data-msbundles="product-count"line countMeta (server render)
data-msbundles="stock"stock messageErrors and warnings
data-msbundles="controls"qty + CTAAction group
data-bundle-quantityinput“Bundles” value
data-msbundles-action="qty-dec" / qty-incbuttonsQuantity stepper
data-msbundles-action="add-bundle"buttonAdd to cart
data-msbundles="product"composition rowBundle line
data-product-idcomposition rowProduct ID
data-msbundles="product-price"line pricePrice in composition
data-msbundles="cart-info"cart blockLead/member metadata
data-bundle-hashcart-info / cart rowLinks bundle rows
data-bundle-leadcart-info / row1 = lead
data-msbundles="name"cart nameName for confirm/remove
data-msbundles-action="remove-bundle"buttonRemove bundle

Legacy data-action with the same values is still accepted. Use data-msbundles-action in new chunks.

You can restyle msbundles__* / msbundles-cart-* in the theme. JS does not depend on those classes (except optional modifiers like --busy set by the script).

Loading CSS and JS

Via snippet (preferred):

fenom
{'!msBundles.initialize' | snippet}
modx
[[!msBundles.initialize]]

The snippet loads msbundles.css, then scripts in order: msbundles-helpers.jsmsbundles-cart.jsmsbundles.js. Before scripts it outputs window.msbundlesConfig and window.msbundlesLexicon.

Config keyDefaultPurpose
autoBindtrueAuto-bind handlers
apiBaseUrl''API prefix if Router is not on the default path
maxBundleQuantityfrom settingQty field limit
calculateDebounceMs350Price recalculation debounce
confirmRemoveBundlefalseconfirm() before “Remove bundle”. Off by default, like a normal MS3 product

Custom config before the snippet:

html
<script>window.msbundlesConfig = { confirmRemoveBundle: true };</script>

“Remove bundle” works like the × on a normal product: ms3.cartUI.handleRemove runs, miniShop3 shows a toast, and the plugin clears the other rows of the set. A separate REST call to /bundle/remove is rare. It is used only when the main row has no product key for the normal cart remove path.

Add and remove need the ms3_token cookie.

JS events

EventWhen
msbundles:beforeBefore calculate / add / remove
msbundles:successSuccessful add / remove
msbundles:errorAPI error
msbundles:updatedPrice recalculation or successful change
html
<script>
document.addEventListener('msbundles:success', function (e) {
  console.log(e.detail.action, e.detail.result);
});
</script>

After add/remove: toast via ms3.message and cart refresh via MsBundles.refreshCart().

Public API window.MsBundles

MethodPurpose
bindAttach handlers to cards and the cart
addBundlePOST add bundle
removeBundleRemove by bundle_hash when the normal cart path is unavailable
calculateRecalculate price and stock
applyCalculateToCardUpdate card price from a calculate response
enhanceCartDisplayLead/member UI on an already rendered table
applyCartRenderFallbackInject HTML if MS3 did not return render
refreshCartGET /api/v1/cart/get with render and refresh msCart blocks
requestLow-level API call
eventsDOM event name map

Bundle in the cart

A bundle sits in the cart as several miniShop3 rows linked by bundle_hash in options. The main row controls quantity. The other products in the set follow it. One “Remove bundle” button clears the whole set.

Without selector on msCart, the toast after add still appears, but the on-page HTML stays stale. selector registers the block in ms3Config.render.cart. After add/remove the front end calls /api/v1/cart/get with render tokens and redraws the cart.

Hook in tpl.msCart

Under the product name, call tplMsBundlesCartInfo:

fenom
{if $product.options.msbundles?}
    {'tplMsBundlesCartInfo' | chunk : $product.options.msbundles}
{elseif $product.options.bundle_hash?}
    {'tplMsBundlesCartInfo' | chunk : [
        'id' => $product.options.bundle_id,
        'name' => $product.options.bundle_name,
        'hash' => $product.options.bundle_hash
    ]}
{/if}

On the table row (<tr>), add data-bundle-hash from the product options. That is how JS knows which rows belong to the same set.

If your cart chunk shows a discounted price, wrap the current price in msbundles-cart-price__now and the old (strikethrough) price in msbundles-cart-price__was. Stock CSS styles them as “now” and “was”.

tplMsBundlesCartInfo placeholders

PlaceholderDescription
idBundle ID
nameBundle name
hashShared bundle_hash for the set
is_lead1 on the main row of the set
line_indexRow number in the set (1 = main)
lines_countHow many products are in the set
quantityHow many bundles the shopper took
RowHow to tellWhat the shopper sees
Mainis_lead=1 or line_index=1“Bundle” badge, name, “Remove bundle”
Otherother rows with the same bundle_hash“Part of bundle…”

If older cart rows lack nested options.msbundles, the msOnGetCart plugin marks the main row for the badge. The product key in the cart does not change.

What JS does after the cart renders

enhanceCartDisplay:

  • marks set rows with attributes and styling classes
  • hides the per-item × on products that belong to the set
  • locks quantity on nested rows and shows an “As in bundle” hint
  • leaves the quantity field only on the main row

When the shopper changes quantity on the main row, the miniShop3 plugin (msOnBeforeChangeInCart) updates the other products in the same set.

When the shopper clicks “Remove bundle” or the × on any row of the set, normal miniShop3 cart removal runs (ms3.cartUI.handleRemove). The plugin (msOnBeforeRemoveFromCart) clears the rest of the set. REST /bundle/remove runs only if that normal cart path is unavailable. See Events.

msbundles.css styles only the bundle block and qty/remove helpers. Logic finds nodes by data-msbundles and data-bundle-*, not by theme class names. In bundle rows, product thumbs are capped at a 4rem square (object-fit: cover).

If your cart chunk does not call tplMsBundlesCartInfo, the shopper will not see the “Bundle” badge, the “Part of bundle…” label, or the “Remove bundle” button.

If the cart does not update

SymptomCheck
Toast yes, block staleNo selector on msCart, or container id mismatch
No badgesMissing tplMsBundlesCartInfo or plugin lacks msOnGetCart
Member qty clickableJS not loaded, or enhanceCartDisplay not run after a manual re-render

Storefront REST

Base: /api/v1/msbundles via the miniShop3 router.

text
/assets/components/minishop3/api.php?route=/api/v1/msbundles/…

Requires a miniShop3 token (ms3_token cookie / TokenMiddleware). Stock msbundles.js calls these routes for you. Use the details below for a custom front end.

Router envelope:

json
{
  "success": true,
  "message": "",
  "data": {},
  "errors": [],
  "warnings": []
}

On error: HTTP 4xx, success: false, data: null.

GET /bundle/calculate

Recalculate price and stock (qty change on the card).

Query: id or bundle_id, quantity.

Example: …/api.php?route=/api/v1/msbundles/bundle/calculate&id=5&quantity=2

data: { "price": {…}, "stock": {…}, "quantity": N }

Besides numbers, price includes strings for the card:

FieldDescription
total_formattedTotal with currency or “Free”
original_total_formattedOriginal
savings_formattedSavings
price_htmlHTML for [data-msbundles="price"] on the card

POST /bundle/add

Add the bundle to the cart. Qty limit: msbundles_max_bundle_quantity.

Body (JSON):

json
{
  "id": 5,
  "quantity": 2
}

ID alias: bundle_id.

data on success:

json
{
  "bundle_hash": "a1b2c3…",
  "quantity": 2
}

Optional lines with zero stock may appear in response warnings. The rest of the bundle still adds.

POST /bundle/remove

Remove every row with the given bundle_hash.

Body (JSON):

json
{
  "bundle_hash": "a1b2c3…"
}

Alias: hash.

data on success:

json
{
  "removed_count": 3,
  "bundle_hash": "a1b2c3…"
}

GET /bundle/get

Bundle card with price and stock (no cart write).

Query: id or bundle_id, optional quantity (default 1).

data: { "bundle": {…}, "price": {…}, "stock": {…}, "quantity": N }