Bundle builder, build spec v3.1

Everything needed to build the v3 demo as a Shopify section the merchant edits in the theme editor, on native discounts, on the theme's own elements, so that a Concept update carries it and nothing depends on an app. File by file, with the gate that closes each step.

Shubham Yadav · 14 Sep 2026, revised the same day after the discount audit · every file, hook, element contract and setting below was checked against the Shubham branch, the Admin API and Shopify's current docs on this date

What "native theme builder" changes

Native wherever a native thing exists. Native discounts, the theme's own bundle element and slot markup, its steppers, its sticky sidebar, its countdown, its buttons, its stylesheet. Our code is one section, one stylesheet and one script that speak to the theme through its public contracts, so a Concept update carries the builder instead of breaking it.

Why this is the governing rule: this store updates Concept by re-applying its customisations on the new copy (the 992px breakpoint and two theme.js guards are already on that list in NANOBAG-DOCS.md). Every theme behaviour we re-implement is one more thing to port; every theme behaviour we inherit is ported for free. The trade is that we accept the theme's rendering where it differs from the demo, and those cases are named below.

Files

FileStateWhat it does
sections/nb-bundle-builder.liquidrewrite draft 1Markup, schema, server-rendered rail and first band, config JSON for the script
assets/nb-bundle-builder.cssrewrite draft 1The v3 demo styles minus preview chrome, scoped to .nb-bb, tokens per campaign
assets/nb-bundle-builder.jsrewrite draft 1<nb-bundle-builder> extends the theme's ProductBundle: four overrides, cart seed, ladder, money; the clock is the theme's countdown-timer
templates/page.bundle.jsoneditAdd one nb-bundle-builder instance; the three stock bundle sections stay in the file, untouched
templates/page.bundle.context.*.jsonnew, 8 filesProduct per market, see the handle table
locales/*.json (32)editsections.nb_bundle_builder.* keys, plural-safe
assets/nb-bundle-modal.jsnew, phase 2<nb-bundle-modal> extends the theme's QuickView
sections/image-with-text-overlay.liquidedit, phase 2Trigger button setting and markup; this is the Rollouts arm
sections/cart-drawer.liquid, snippets/free-shipping-bar.liquid, sections/overlay-group.jsonedit, own commitFree-shipping bar switches from a money threshold to the weight rule
assets/custom.cssedit, own commitsection#shopify-pc__banner { z-index: 34 }, not on Shubham today

Not touched: layout/theme.liquid, assets/theme.js, any app-owned file, settings_data.json. The popup fetches the page and extracts a fragment, so no chromeless layout gate is needed.

Section schema

Settings the merchant sees, in editor order. Ranges and defaults are the ones the demo runs with.

{
  "name": "NB Bundle builder",
  "tag": "section",
  "class": "section-nb-bundle-builder",
  "settings": [
    { "type": "product", "id": "product", "label": "Bundle product",
      "info": "Pick this market's product while previewing that market." },
    { "type": "text", "id": "heading", "label": "Heading", "default": "Build your bundle" },
    { "type": "text", "id": "subheading", "label": "Offer sentence (everyday)",
      "default": "Three bags ship free. Ten bags save 25% and come with 7 more free." },

    { "type": "header", "content": "Campaign" },
    { "type": "select", "id": "campaign_mode", "label": "Mode", "default": "auto",
      "options": [
        { "value": "auto", "label": "Automatic from the dates" },
        { "value": "everyday", "label": "Force everyday" },
        { "value": "bf", "label": "Force Black Friday" },
        { "value": "cyber", "label": "Force Cyber Monday" } ] },
    { "type": "text", "id": "bf_starts_at", "label": "Black Friday starts", "default": "2026-11-27T00:00:00+08:00",
      "info": "ISO 8601 with the offset. Store time is HKT." },
    { "type": "text", "id": "bf_ends_at", "label": "Black Friday ends", "default": "2026-11-29T23:59:59+08:00" },
    { "type": "text", "id": "cyber_starts_at", "label": "Cyber Monday starts", "default": "2026-11-30T00:00:00+08:00" },
    { "type": "text", "id": "cyber_ends_at", "label": "Cyber Monday ends", "default": "2026-11-30T23:59:59+08:00" },
    { "type": "text", "id": "offer_bf", "label": "Offer sentence (Black Friday)", "default": "Up to 35% off. Three bags ship free." },
    { "type": "text", "id": "offer_cyber", "label": "Offer sentence (Cyber Monday)", "default": "Up to 40% off. Fifteen bags is the deepest rung." },
    { "type": "checkbox", "id": "show_stockup", "label": "Show the 15-bag stock-up card on Cyber Monday", "default": true },

    { "type": "header", "content": "Rewards" },
    { "type": "range", "id": "free_shipping_bags", "label": "Free shipping from (bags)", "min": 0, "max": 10, "step": 1, "default": 3,
      "info": "Shipping is weight based: 49.9 g, and every bag is 20 g. Three is the first true value. Zero hides the rung." },
    { "type": "checkbox", "id": "show_preset", "label": "Show the \"Add N more\" button", "default": true },

    { "type": "header", "content": "Layout" },
    { "type": "range", "id": "padding_top", "min": 0, "max": 100, "step": 4, "unit": "px", "default": 56, "label": "Top padding" },
    { "type": "range", "id": "padding_bottom", "min": 0, "max": 100, "step": 4, "unit": "px", "default": 96, "label": "Bottom padding" }
  ],
  "blocks": [
    { "type": "rung", "name": "Rung", "limit": 10, "settings": [
      { "type": "range", "id": "bags", "label": "Bags", "min": 1, "max": 20, "step": 1, "default": 4 },
      { "type": "range", "id": "pct_everyday", "label": "% off, everyday", "min": 0, "max": 60, "step": 5, "default": 0 },
      { "type": "range", "id": "pct_bf", "label": "% off, Black Friday", "min": 0, "max": 60, "step": 5, "default": 0 },
      { "type": "range", "id": "pct_cyber", "label": "% off, Cyber Monday", "min": 0, "max": 60, "step": 5, "default": 0 },
      { "type": "text", "id": "reward_fallback", "label": "Reward text if gift rules are unavailable",
        "info": "Gifts are read live from the gift rules. This only shows before that script loads." } ] }
  ],
  "presets": [ { "name": "NB Bundle builder", "blocks": [
      { "type": "rung", "settings": { "bags": 1,  "reward_fallback": "Free carabiner" } },
      { "type": "rung", "settings": { "bags": 2,  "pct_bf": 10, "pct_cyber": 10 } },
      { "type": "rung", "settings": { "bags": 3,  "reward_fallback": "Free shipping" } },
      { "type": "rung", "settings": { "bags": 4,  "pct_everyday": 10, "pct_bf": 20, "pct_cyber": 20, "reward_fallback": "1 free bag" } },
      { "type": "rung", "settings": { "bags": 6,  "pct_everyday": 15, "pct_bf": 25, "pct_cyber": 25, "reward_fallback": "3 free bags and 3 carabiners" } },
      { "type": "rung", "settings": { "bags": 8,  "pct_everyday": 20, "pct_bf": 30, "pct_cyber": 30, "reward_fallback": "5 free bags and 5 carabiners" } },
      { "type": "rung", "settings": { "bags": 10, "pct_everyday": 25, "pct_bf": 35, "pct_cyber": 35, "reward_fallback": "7 free bags and 7 carabiners" } },
      { "type": "rung", "settings": { "bags": 15, "pct_cyber": 40 } } ] } ],
  "disabled_on": { "groups": ["header", "footer", "custom.overlay"] }
}

Visibility rule, applied in Liquid and again in JS: a rung renders in campaign C when pct_C > 0, or bags == free_shipping_bags, or tiersSpec has a threshold equal to bags. Blocks are sorted by bags ascending regardless of editor order. Two blocks with the same bags is a theme-check error we add ourselves.

Data: one source per fact

FactSourceRead by
Gift per thresholdNB_GIFT_RULES.tiersSpec, cumulative increments {threshold, bag, carab}, market resolvedJS, summed by threshold at boot
Cash percentage per rung per campaignsection blocks, mirrored by one native automatic discount per rungLiquid renders, JS reads the config JSON; checkout reads the discounts
Free-shipping rungsection settingboth
Active campaignsection dates, evaluated in the browser, 60 s intervalJS
Prices, colour names, availabilitythe market's product, server renderedLiquid; never re-fetched
Bag count for the ladder/cart.js on boot and on every cartUpdate, excluding lines that carry _nb_giftJS
Session pickssessionStorage, keyed by section id and marketJS

Gift derivation from tiersSpec

// tiersSpec: [{threshold:1,bag:0,carab:1},{threshold:4,bag:1,carab:0},{threshold:6,bag:2,carab:2}, ...]
// cumulative: at 6 bags you hold 3 free bags and 3 carabiners
const cum = []; let bag = 0, carab = 0;
for (const t of spec.sort((a, b) => a.threshold - b.threshold)) {
  bag += t.bag; carab += t.carab;
  cum.push({ bags: t.threshold, fb: bag, car: carab });
}
// a rung's gift = the cum entry with the largest bags <= rung.bags

The discount is native

One Shopify automatic "amount off products" discount per rung that carries a percentage, scoped to the bundle-eligible collection, minimum quantity equal to the rung, scheduled per campaign. No app. This is what the store already ran from November 2022 to September 2024 ("4 / 6 / 8 bags - bundle discount") and again for BFCM 2024, verified in the Admin API on 14 Sep. The two Shopify rules it rests on, verified against the help centre the same day: when eligible discounts cannot combine, "the best discount for the customer's cart is always applied", and for a collection-scoped discount "only these items contribute to the minimum quantity". Gift lines are free because the gift variants are priced at zero, not through a discount, so nothing has to combine with them; the gift products simply stay out of the collection.

DiscountMin qtyOffActive
Bundle: 4 bags410%always
Bundle: 6 bags615%always
Bundle: 8 bags820%always
Bundle: 10 bags1025%always
Black Friday: 2 bags210%27 to 29 Nov
Black Friday: 4 / 6 / 8 / 10 bags4 / 6 / 8 / 1020 / 25 / 30 / 35%27 to 29 Nov
Cyber Monday: 2 / 4 / 6 / 8 / 10 bags2 / 4 / 6 / 8 / 1010 / 20 / 25 / 30 / 35%30 Nov
Cyber Monday: 15 bags1540%30 Nov

Combines with: order discounts yes (a VIP code can add on top), shipping yes, product discounts yes, the 2022 configuration, so One Click Upsell's product function keeps combining; tiers still cannot stack on one line because that needs Plus tag matching and we set no tags. The everyday set stays active during the campaigns; "best applies" picks the campaign tier. Capacity: 6 campaign discounts plus 4 everyday plus 3 One Click Upsell = 13 of the 25 cap, and 7 outside a campaign. Simple Discounts stays installed and unused; it comes back only if a v2 offer needs per-currency fixed prices, which native cannot express.

The market cache guard from the September plan stays: on boot compare the rendered market handle to the localization cookie; on mismatch reload once (a sessionStorage flag prevents a loop). A reload, not a client-side patch, because the product JSON endpoint returns untranslated option values.

The script: extend the theme's element, do not replace it

<nb-bundle-builder> is class NbBundleBuilder extends customElements.get('product-bundle'), registered inside customElements.whenDefined('product-bundle').then(...) because theme.js is deferred. The receipt uses the theme's own slot markup, so every inherited method finds what it expects. Four methods are overridden; everything else is the theme's.

MethodOurs or inheritedWhy
addVariant(product, variant)overrideThe theme opens a new slot per add. We find an existing slot with the same data-variant-id and bump its quantity-input; only a new variant opens a slot. Then call the inherited method for the media and content fill.
updateProgressBar()overrideThe theme fills toward minimum_quantity. Ours fills the rail by rung index and updates the dots, the money hero and the next line.
template (getter)overridePoints at our one hidden available slot so the theme's cloning works without rendering minimum_quantity empty rows.
onSubmitHandler(event)overrideThe theme's version calls resetVariants() on a 422, which wipes the shopper's picks. Ours keeps the theme's request and success path (same fetchConfig, cart:bundled-sections, cartUpdate publish, ajaxProduct:added, drawer unless flyToCart) and adds the drop-and-retry loop below.
updateTotal, updateTotalWithCurrency, clearVariant, reorderVariants, resetVariants, handleErrorMessage, bundleMin, bundleMax, getAvailableAvriant, getResizedImageSrcinheritedUntouched. A theme fix to any of them reaches the builder on update.
seedFromCart(), ladder(), campaign(), money()newCart seeding on boot and on cartUpdate, rung visibility and gifts from tiersSpec, campaign clock, savings maths.

The tile is the theme's bundle form

<div class="card product-card nb-bb__tile" data-variant="{{ v.id }}">
  <span class="sr-only" data-product-bundle-title>{{ style_name }}</span>   <!-- addVariant() reads this -->
  <form is="product-bundle-form" aria-controls="{{ bundle_id }}" data-product-image="{{ v.featured_media | image_url: width: 180 }}">
    <input type="hidden" name="id" value="{{ v.id }}">
    <script type="application/json" data-selected-variant>{{ v | json }}</script>
    <button type="submit" class="nb-bb__tile-btn" aria-label="Add {{ style_name }}, {{ colour }}, {{ v.price | money }}">...</button>
  </form>
  <div class="nb-bb__step" hidden>  <!-- minus dispatches bundle:removed with the slot; plus re-submits the form -->
</div>

Submitting that form makes the theme dispatch bundle:added with the variant JSON; nothing of ours is in that path. The grid is a <motion-list> and tiles carry .card, so the theme's own reveal animation runs on them.

The receipt is the theme's sidebar

<sticky-element class="product-bundle__sidebar" aria-controls="shopify-section-header" data-gap="16"> wrapping: our money hero, one hidden available slot, then bag rows as the theme's .horizontal-product block with [data-product-bundle-variant], [data-product-bundle-variant-media], [data-product-bundle-variant-content], [data-product-bundle-variant-price], a <quantity-input> and a <product-bundle-remove-button>, then [data-product-bundle-total], [data-product-bundle-total-with-currency], a <button class="button button--primary" is="hover-button" data-product-bundle-submit> and the theme's .product-form__error-message. Those classes are styled in theme.css (with the mini-cart polish in custom.css); our stylesheet re-skins them per campaign and adds nothing structural.

Other theme elements used as they are

The 422 loop, required

POST items[]            -> 422 (all or nothing; stale inventory on a cached page is normal)
  parse description     -> find the variant id it names
  mark that tile sold out in place, drop that slot, retry
  max 3 drops, then stop and show "Could not add to cart. Nothing was charged and your picks are still here."
on success after drops  -> "3 bags added. Sling, Duck sold out while you shopped and was not added."

The script tag keeps data-cookieconsent="ignore" and reads theme.settings at boot, so it belongs to the Cookiebot boot-chain set. Nothing about script order or consent attributes changes in this work. The section renders the rail and the first band from Liquid, so a pre-consent EU visitor sees the offer and the merchandise with the script deferred; the tile forms still post to /cart/add without JavaScript.

Theme contract surface, and the checklist after a Concept update

Everything the builder depends on from the theme, by name. After any base-theme update, grep each one in the new copy; if it is still there, the builder runs. This list goes into NANOBAG-DOCS.md next to the two theme.js guards already tracked there.

KindContractWhere defined today
Custom elementsproduct-bundle, product-bundle-form, product-bundle-remove-button, quantity-input, sticky-element, progress-bar, countdown-timer, motion-list, hover-button, quick-viewassets/theme.js
Eventsbundle:added, bundle:removed, cart:bundled-sections, ajaxProduct:added, ajaxProduct:error, pubsub cartUpdateassets/theme.js
Data attributesdata-product-bundle-variant, -variant-media, -variant-content, -variant-price, -total, -total-with-currency, -submit, -progress-bar, -title, data-selected-variant, data-product-image, data-minimum, data-maximumsections/product-bundle.liquid, theme.js
Globalstheme.routes.cart_add_url, theme.routes.cart_url, theme.utils.fetchConfig, theme.Currency.formatMoney, theme.settings.moneyFormat, theme.settings.flyToCart, theme.config.motionReduced, theme.pubsub, window.NB_GIFT_RULESsnippets/js-variables.liquid, theme.js, snippets/nb-gift-rules.liquid
CSS classes.product-bundle__sidebar, .horizontal-product*, .button, .button--primary, .btn-fill, .btn-text, .product-form__error-message, .countdown__item, .cardassets/theme.css

Also on the re-apply list, unchanged by this work: the 992px breakpoint, and the two theme.js guards. The builder adds no theme.js edits of its own.

Page, template and markets

Page Build your bundle, handle build-your-bundle, template page.bundle, created unpublished. The template gets one nb-bundle-builder instance appended after rich-text; the stock bundle sections in that file are left in place and stay unused.

Context fileMarketProduct handle
page.bundle.json (base)United Statesreusable-shopping-bags
page.bundle.context.canada.jsonCanadananobag-ca
page.bundle.context.united-kingdom.jsonUnited Kingdomnanobag-uk-1
page.bundle.context.eu-markets-translated.jsonEUnanobag-eu
page.bundle.context.italy.jsonItalynanobag-eu
page.bundle.context.spain.jsonSpainnanobag-eu
page.bundle.context.singapore.jsonSingaporenanobag-sg
page.bundle.context.hong-kong.jsonHong Kongnanobag-int
page.bundle.context.international.jsonInternationalnanobag-int

Handles taken from the homepage context files on 14 Sep. The UUID context on the homepage carries no product and is not copied. Context files are committed before shopify theme dev ever runs, because dev rewrites uncommitted JSON.

Copy: locale keys

All under sections.nb_bundle_builder, following sections.freebie_banner. Plural keys use one / other and are read with | t: count: n. English first; the other 31 files get the English string so nothing renders "Translation missing" on da or pl, then real translations follow the existing scripts/register-*-translations.mjs route.

heading, offer_everyday, offer_bf, offer_cyber, banner_bf, banner_cyber, banner_deal ("Up to {{ pct }}% off")
rung_pct ("{{ pct }}% off"), rung_free_shipping, rung_carabiner, gift_bags (plural), gift_carabiners (plural)
rail_next (plural: "{{ count }} more bag for {{ reward }}" / "{{ count }} more bags for {{ reward }}")
rail_top ("Deepest rung reached at {{ pct }}% off")
band_from ("from {{ price }}"), show_all (plural), show_fewer
tile_add ("Add {{ style }}, {{ colour }}, {{ price }}"), tile_plus, tile_minus, sold_out
bundle_label, empty_nudge, money_saved ("saved with {{ pct }}% off"), money_count (plural), at_checkout ("{{ count }} bags, {{ total }} at checkout")
gifts_worth ("Worth {{ amount }}"), gifts_included, preset (plural), cta (plural), cta_empty, cta_busy, cta_retry, fine_print
success (plural), success_sub, view_cart, keep_building, err_partial (plural), err_total, err_network
stockup_title, stockup_sub, stockup_cta, countdown_far, countdown_tomorrow, countdown_hours (plural), countdown_minutes (plural), countdown_final
trust_row, faq_1_q, faq_1_a, faq_2_q, faq_2_a, faq_3_q, faq_3_a, done, skip

Two fixes that ship before the builder, as their own commits

1. The cart drawer's free-shipping bar contradicts the weight rule

sections/cart-drawer.liquid:92-115 picks a money threshold per currency from overlay-group.json (USD:40, GBP:31.23, ...) and snippets/free-shipping-bar.liquid fills a bar from cart.total_price. Shipping is actually free at 49.9 g. Two Daypacks are $51.90 and 40 g: the drawer says free, checkout charges. The builder will say "3 bags ship free" beside it.

Gate: two Daypacks in the drawer show "1 more bag for free shipping" in every market; three show the bar full; checkout agrees.

2. The privacy banner covers the mobile CTA

Shopify's banner sits at z-index 2000000 and covered a drawer checkout button on this store in August. The docked bundle bar is the only path to the cart on a phone. section#shopify-pc__banner { z-index: 34; } in assets/custom.css; it exists in a worktree, not on Shubham.

Phase 2: the popup on the homepage and PDP

<nb-bundle-modal> extends the theme's QuickView. It fetches /pages/build-your-bundle with ?country= forcing, extracts #nb-bb-modal-content, injects it into the drawer body, and lets QuickView handle open, close, focus and the auto-hide on cartUpdate. The section wraps its inner in that id so the same file serves both the page and the modal.

Rollouts test, 12 to 26 October

The page itself is new traffic and needs no arm. The arm is the trigger: nb_bundle_trigger off (control) vs on (treatment) on both hosts, EDIT mode, 7 markets, EU out, the NB08 and NB09 shape. Read it with sessions.rollout_treatment_ids and sales.rollout_treatment_id: sessions, bundle page views, add rate, orders per session, AOV, bags per order.

Build order and gates

  1. Shubham refresh. Commit the in-flight work in its groups, git merge origin/main, resolve.

    Gate: templates/index.json and templates/product.json identical to main before any bundle edit.

  2. The two fixes above, each its own commit.

    Gate: drawer bar in bags; banner z-index verified on a phone with the banner open.

  3. Native discounts. The bundle-eligible smart collection (tag bundle_eligible on the paid bag products, never on gift products), then the discounts in the table above, created in admin, scheduled. Stage them on nanobag-test-store first: the products are imported there, the collection is one smart collection to add.

    Gate: on staging, a 4-bag cart checks out at 10%, 6 at 15%, 10 at 25%, 2 Daypacks at 0%; with the Black Friday set force-activated, 4 bags check out at 20% and never 30%. Screenshots kept. Live discounts are created only when the page goes live, on your say.

  4. Section, CSS, JS. The demo's markup and styles ported, the theme primitives swapped in, the schema above.

    Gate: shopify theme check clean, proven to scan the file with a throwaway offense; probe passes every state at 360, 375, 390 and 1440; zero em-dashes; AA on every measured pair; innerWidth == clientWidth at all four widths.

  5. Data. Gifts from tiersSpec, cart seeding, the 422 loop, the market guard.

    Gate: ?country=DE, ?country=PL, ?country=SG render their own prices and translated colour names; a stale-inventory 422 recovers with the right message.

  6. Locale keys, 32 files.

    Gate: no "Translation missing" on da or pl.

  7. Template, contexts, page.

    Gate: pre-consent EU pageview shows rail and shelf with the script deferred; QA_CONSENTCHECK=1 passes; every context file byte-verified by CLI pull after the theme push, never trusted per commit.

  8. Client review on the Shubham theme, then the popup.

    Gate: modal opens from both hosts, no header inside it, closes on cartUpdate.

Pushes are always shopify theme push --only <changed files> to the Shubham theme (150265266312). Nothing reaches main or live without your say; promotion is by single cherry-picked commits, each verified per file.

Rollback