Shopify Builds>Three Ships>Subscription bundle grid with tiered unlock progress

Subscription Bundle Grid With Tiered Unlock Progress

Two custom elements on the subscription landing page: a card whose two-step button reveals a frequency choice and then posts the variant with that selling_plan to the cart, and a wrapper that fetches the live cart after every add and drives a merchandiser-defined tier bar from what it finds there.

A Shopify cart line only carries a selling plan if the add request sends one, and a theme's stock quick-buy sends the variant alone. On Three Ships' subscription page we built a card that adds a product under a chosen frequency, and a wrapper that re-reads the live cart after every add, moving a tier progress bar by counting only subscription lines for grid products. Nothing increments a counter; the cart is the state.

The Problem

A cart line becomes a subscription line only when the add request includes a selling_plan id. Post the variant alone and Shopify records a one-time purchase, whatever plans the product offers. A grid quick-buy is exactly that kind of request: one button, one variant, no plan. So a subscription product in a product grid can be added to the cart, but never as a subscription.

Three Ships wanted a landing page where a shopper assembles a multi-product subscription in one place — pick a delivery frequency for each product, add it, and watch a progress bar say how many more items the next tier needs. The theme's own product card couldn't do the first step, so every card sent the shopper to the product page, and the bundle was never assembled on the page that promised it.

The Constraint

The stock quick-buy in Palo Alto 5.8.0 has no selling-plan support, so the card had to be rebuilt — but it still had to read as one of the theme's own cards, in the theme's carousel, on a page a merchandiser lays out in the editor. Tiers, bar copy and the product list had to be editable without a deploy.

The cart's user interface on this storefront is Rebuy's Smart Cart, which keeps its own view of cart state, so a theme-side add has to tell it to refetch.

And the bar had to be right in states the page doesn't control: after a refresh, a back-navigation, a removal inside the drawer. None of those events reach the page. Only the cart knows the true count, so the progress bar couldn't own its own number.

What We Built

Three files: bundle-grid.liquid is the section and the merchandiser surface, bundle-product-card.liquid renders one card per subscription-enabled product, and bundle-grid.js defines two custom elements that do all the runtime work.

<bundle-grid-card> owns one card and one variant. Its call to action is a two-step control. The first click adds is-expanded to .bundle-card__frequencies, revealing one button per entry in variant.selling_plan_allocations, and relabels the button to ADD TO CART. Each frequency button carries data-plan-id and data-plan-price; choosing one sets the selected plan and swaps the price suffix on the button with no network call. The second click builds a FormData of id, quantity: 1 and selling_plan and posts it to window.theme.routes.cart_add_url, falling back to /cart/add.js. On success the element dispatches a bubbling bundle-grid:item-added event and, if the global Rebuy.Cart object exists, calls its fetchCart method so the drawer catches up.

<bundle-grid> owns the progress bar, and owns no count of its own. At construction it parses data-product-ids off itself into an allow-list, reads every .bundle-grid__tier element's data-tier-count and data-tier-label, and sorts the tiers ascending. Then it calls syncFromCart() — once on boot, and again every time a bundle-grid:item-added event bubbles up from a card. syncFromCart() fetches /cart.js and sums the quantity of every line whose product_id is in the allow-list and which carries a selling_plan_allocation. That sum is the count. updateProgress() turns it into a fill width, marks reached tiers with is-reached, and writes the helper line from the merchandiser's template.

CARD · bundle-grid-cardWRAPPER · bundle-gridSHOPIFY CART Click 1 · choose frequency data-plan-id → selling_plan Click 2 · POST the add id · quantity · selling_plan bundle-grid:item-added bubbles → syncFromCart() /cart/add.js line gains a selling plan /cart.js the whole cart, as it is now count = Σ quantity where product_id ∈ data-product-ids && selling_plan_allocation → fill width · reached tiers · helper text selling_plan in the body event, not a number re-read, every time also on boot no local counter, no count += 1
As the code expresses it: the card posts the add and raises an event; the wrapper re-reads the cart and recounts. The arrow that isn't there — from the add straight to the number — is the design.

bundle-grid.liquid is the merchandiser's half. A product_list picker (limit 15) supplies the products. Up to six tier blocks each carry an item_count and a discount_label; Liquid finds the largest count, positions each tier's dot on the track at item_count × 100 / max_tier_count, and stamps the same two values onto the element as data attributes for the script. A free-shipping badge, a progress message with [count] and [discount] placeholders, and an all-unlocked message are plain text settings. data-product-ids is assembled in Liquid from only those picked products with selling_plan_groups.size > 0 — the allow-list the cart sync counts against. In the theme editor, a picked product with no selling plans renders a visible warning card naming it, so the merchandiser sees why their pick didn't appear.

Why This Way

Counting from /cart.js rather than keeping a number in the page is the deciding choice. A counter that increments on click is right for exactly one session state — the shopper started at zero, added only here, removed nothing. Everything else gets it wrong: a refresh, a back-navigation, a removal in Rebuy's drawer, an item already in the cart from the product page. The cart knows the answer, so the wrapper asks it every time and never trusts itself.

Requiring selling_plan_allocation on a counted line keeps a one-time purchase of the same product from advancing a tier. The allow-list keeps a subscription bought elsewhere on the site from advancing it either.

We accepted a fetch after every add and on every page load for that, and a bar only as current as its last fetch. The two-step button exists because a frequency has to be chosen before an add is meaningful, and it keeps every collapsed card the same height as its neighbors. The card also carries exactly one variant — selected_or_first_available_variant — so a product whose variants differ in more than plan has no picker here. We chose that limit over a variant selector inside a grid card.

Why Not an App

A subscription vendor's purchase-option widget is a product-page control: one product, one selector, its own markup. The apps that assemble a box on a landing page bring their own cards, keep bundle state in their own store, and render around the theme rather than inside it. Neither shape gave Three Ships a grid that looks like the storefront's other grids and a tier bar the merchandiser can edit in the theme editor.

So the card is rendered by the theme, not by an app, the tiers are theme blocks, and the runtime dependency is Shopify's cart AJAX API and nothing else. The selling plans themselves are Recharge's; the card reads them through variant.selling_plan_allocations like any other Liquid. Recharge's widget continues to serve the product page. Rebuy's Smart Cart remains the cart the shopper sees, and the card tells it when something changed. The bundle isn't a thing this page holds — it's the cart.

Implementation Notes

  • Both elements are plain custom elements — customElements.define('bundle-grid-card') and customElements.define('bundle-grid') — with no framework. The section loads bundle-grid.js with defer and its own bundle-grid.css through stylesheet_tag.
  • A card is emitted only when product.selling_plan_groups.size > 0. Under request.design_mode a non-subscription pick renders a warning card with the product's title instead of silently vanishing.
  • data-product-ids is built in Liquid by appending only subscription-enabled product ids, so the allow-list the script counts against and the cards it renders can never disagree about which products are in the grid.
  • Frequency buttons carry data-plan-id and data-plan-price (allocation.price | money_without_trailing_zeros); clicking one updates the selected plan and the CTA's price suffix locally, with no request until the add itself.
  • Rebuy is refreshed defensively — a typeof Rebuy !== 'undefined' && Rebuy.Cart guard before the fetchCart call — so the card runs unchanged on a page where the drawer hasn't loaded.
  • showError() writes the failure onto the button for 2.5 seconds and stores the label to restore; it guards a retry inside that window from capturing the error text as the label.
  • Tiers are sorted ascending in the script, because editor blocks can be dragged into any order and updateProgress() treats the last tier as the maximum.
  • The script's fill percentage uses the same count-over-max ratio as the Liquid that positions the dots, so the fill lands on each dot instead of drifting past it.

Edge Cases

  • A card with no selling plans can't exist outside the editor; if one somehow renders, the first click bails with a 'Not available' error state rather than expanding into a dead ADD TO CART.
  • Shopify's own add errors — data.status set, an out-of-stock variant, say — are surfaced on the button via showError(data.description) instead of failing silently.
  • A failed /cart.js fetch is caught and updateProgress() still runs, so a network blip leaves the last known bar rather than throwing.
  • With every tier reached the helper text switches to the merchandiser's all-unlocked message; otherwise it pluralizes 'item' or 'items' for the remaining count.
  • No tier blocks at all: Liquid renders no progress bar and updateProgress() returns early, so the grid works as a plain subscription grid.
  • One-time purchases of a grid product are excluded from the count by requiring selling_plan_allocation on the line, so a subscribe-and-save tier can't be reached with one-off items.
  • The section is disabled_on the header, aside and footer groups, so it can't be dropped into a section group where its carousel wouldn't run.

Platform Primitives Used

  • Selling plans — the card renders one button per variant.selling_plan_allocations entry, gates on product.selling_plan_groups.size, and the count requires selling_plan_allocation on a cart line.
  • Cart AJAX API/cart/add.js carries the selling_plan, and /cart.js is read back as the only source of the count.
  • Custom elements — two elements, one per card and one per grid, communicating by a bubbling CustomEvent.
  • Theme blocks — up to six tier blocks, each an item count and a label, positioned by Liquid and read by the script.
  • Liquid section schema and settings — the product picker, the placeholder-templated messages, the badge, the column counts and the spacing controls.
  • Metafields — card copy from custom.short_product_title (falling back to product.title) and custom.product_mini_description; the custom badge from theme.badge.

Integrations in Play

  • Rebuy — the storefront's cart drawer. The card coexists with it by calling its refetch after a successful add, and the wrapper's own count comes from Shopify's cart, not from Rebuy's.
  • Recharge — the source of the selling plans the card offers. Its product-page widget sits alongside this grid; the grid doesn't touch it.
  • Yotpo — a star-rating widget mounts inside each card, in the same position it holds on the theme's other cards.

Where It Runs

One template mounts the section: page.subscription, the storefront's subscription landing page. The surface is the product listing — a product grid with cards, badges and hover media — placed on a page rather than a collection. There's no product-page instance; on the PDP the selling-plan control is the subscription app's own.

What This Demonstrates

  • Subscription purchase options on the product page — the primary capability: a selling-plan selector and a per-frequency price rendered by the theme, here moved off the product page and into a grid card.
  • Subscription bundles and selling-plan orchestration — several products added under their own plans and read back as one subscription bundle.

How We Know

Three files from the theme — the section, the card snippet and the script — plus five entries in the client's task register. The register carries the tier bar as requested work, and later refinements to the carousel and the styling after it shipped.

Documented from the code; no client-side record of the decision survives.

Related Builds

The Buy-vs-Build Question

Nobody built a subscription engine here. The plans, the billing and the customer portal are bought; what got built is the way a shopper picks several of those plans on one page and sees where they stand. That split — rent the engine, own the surface — is the position we argue on Product subscriptions: buy or build?

Provenance & Evidence

  • Client: Three Ships — threeshipsbeauty.ca
  • Surface: Product listing — a product grid on the subscription landing page
  • Templates served: One — page.subscription
  • Complexity: Medium — roughly 900 lines across a section, a snippet, a script and a stylesheet
  • Attribution: Deploi-authored. The section, the card snippet and both custom elements are ours. They run inside Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the brand licenses; the carousel, the global card settings and the design tokens the card inherits are the vendor's.
  • Status: Live, verified 2026-09-07
  • Evidence: Three theme files read directly, plus five task-register entries
  • Confidence: Moderate — documented from the code; no client-side record of the decision survives
  • Primary capability: Subscription purchase options on the product page

Ready to Let Shoppers Build a Subscription Without Leaving the Page?

If your subscription products can only be subscribed to one product page at a time, the missing piece is usually a card, not a platform. Contact us today and we'll work out what your grid needs to carry — and what your cart already knows.

More builds