# Guide to Recharge Bundles & Custom Bundles Widgets
This is an llms.txt file that aims to help developers how to load Recharge bundles configuration and how to build custom bundles widgets.
## Start Here: Determine Your Implementation Path
**Answer these 4 questions to find your path:**
1. **What are you doing?**
- Creating a new bundle widget from scratch
- Troubleshooting/fixing an existing implementation
2. **Where are you building?**
- Shopify theme (Online Store)
- Headless/custom store
3. **What bundle type?**
- **Fixed Price Bundle**: Bundle has set price, selections don't change total
- **Dynamic Bundle**: Price changes based on customer selections
4. **What data do you need?**
- Basic product info (name, price, image)
- Custom data (metafields, tags, etc.)
**CRITICAL: Confirm Bundle Type First**
- Fixed price bundle or Dynamic bundle
- If not specified, STOP and ask directly
- Do NOT assume or guess
- Supporting both types increases complexity (possible but discouraged)
**CRITICAL: Determine Implementation Type**
**Online Store (Theme-based):** Use if building within Shopify themes (Liquid templates), no custom product data needed
**Headless/Custom:** Use if building headless (custom storefronts, mobile apps, PWAs) OR need custom metafields/rich product data
**Based on your answers:**
**Path A: Fixed Price Bundle** (New + Either context + Fixed Price + Basic)
- **Follow:** Loading Recharge Bundle Settings β Fixed Price Bundle Implementation β Cart Integration
**Path B: Dynamic Bundle** (New + Either context + Dynamic + Basic)
- **Follow:** Loading Recharge Bundle Settings β Dynamic Bundle Implementation β Cart Integration
**Path C: Enhanced Bundle Widget** (New + Either context + Either bundle type + Custom data)
- **Follow:** All sections including Advanced Features
**Path D: Troubleshooting** (Troubleshooting + Any answers)
- **Jump to:** Debugging sections
## How to Use This Guide
**Follow your path from above:**
- **REQUIRED** sections: Core implementation for all paths
- **OPTIONAL** sections: Enhanced features (skip if not needed)
- **REFERENCE** sections: Debugging and troubleshooting
**When stuck:** Check Production Checklist and Debugging sections
---
## π Table of Contents
### β‘ REQUIRED: Core Implementation
- [Loading Recharge Bundle Settings](#loading-recharge-bundle-settings)
- [Building Bundle Widgets for Shopify Online Store](#building-bundle-widgets-for-shopify-online-store-theme-based)
- [Building Bundle Widgets for Headless/Custom](#building-bundle-widgets-for-headless-custom-implementations)
- [Adding Recharge Dynamic Bundles to Cart](#adding-recharge-dynamic-bundles-to-cart)
- [Fixed Price Bundle Implementation](#fixed-price-bundle-implementation)
### π¨ OPTIONAL: Enhanced Features
- [Working with Shopify Metafields](#working-with-shopify-metafields-in-bundle-widgets) *(Skip if you don't need custom product data)*
- [Dynamic Bundle Price Handling](#dynamic-bundle-price-handling---special-implementation-requirements)
- [Real-World Selling Plan Matching](#real-world-selling-plan-matching-patterns)
### π§ REFERENCE: Advanced Topics
- [Production Checklist](#production-checklist--critical-requirements)
- [Debugging Selling Plan Issues](#debugging-selling-plan-matching-issues)
- [Troubleshooting Guide](#debugging-selling-plan-matching---real-implementation-example)
---
## β‘ REQUIRED: Loading Recharge Bundle Settings
To load bundle configuration from Recharge, use the CDN version of their SDK:
1. **Include the SDK Script**
```html
```
> **π‘ Note:** Check [https://storefront.rechargepayments.com/client/docs/changelog/](https://storefront.rechargepayments.com/client/docs/changelog/) for the latest SDK version. Update the version number in the URL above if a newer version is available.
2. **Initialize the SDK**
```javascript
await recharge.init({
storeIdentifier: 'your-store.myshopify.com',
appName: 'Your App Name',
appVersion: '1.0.0'
});
```
3. **Fetch Bundle Settings**
There are two methods available for obtaining bundle settings, depending on your implementation type:
**For Shopify Online Store (Theme-based) implementations:**
- Use `recharge.bundleData.loadBundleData()`
- Returns complete bundle data including:
- Bundle settings
- Full collection and product information
- Everything needed to build your widget
**For Headless/Custom implementations:**
- Use `recharge.cdn.getCDNBundleSettings()`
- Returns only bundle settings
- You'll need to fetch collections and product information separately using Shopify's GraphQL API
## Building Bundle Widgets for Shopify Online Store (Theme-based)
For Shopify theme implementations, Recharge provides a streamlined method that includes all necessary data in a single optimized call.
- Do NOT ask for identifiers or data you can derive at runtime
- Auto-derive from Liquid product context:
- `storeIdentifier`: `{{ shop.permanent_domain }}`
- `bundleProductId`: `{{ product.id }}`
- `bundleProductHandle`: `{{ product.handle }}`
- `bundleVariantId`: `{{ product.selected_or_first_available_variant.id }}`
- Do NOT ask for: Shopify store domain, bundle product ID, collection IDs, or selling plan setup confirmations
- Load these from `recharge.bundleData.loadBundleData()` and product context
### Block-First (Required for Online Store)
To make the widget embeddable anywhere and keep logic reusable, implement it as a theme block and use a thin section wrapper plus a dedicated product template:
- Block: All widget logic and UI live in a theme block (type handle aligned with naming, e.g., `bundle-builder`).
- Section wrapper: Minimal section that only renders its blocks; used to place the block in templates or presets. No business logic here.
- Product template: JSON template that includes the wrapper section with a default instance of the block.
#### Block Placement (Theme)
- Preferred (theme supports top-level blocks): place the block at `blocks/bundle-builder.liquid`.
- Fallback (if `blocks/` directory is not present in the theme): keep a thin wrapper section `sections/bundle-builder.liquid` and put all widget logic/UI in `snippets/bundle-builder.liquid` (treat the snippet as the βblockβ logic). The section remains thin (no business logic, no network calls).
#### File Path Resolution Rules (Theme only)
1) If a `blocks/` directory exists at the theme root β create `blocks/bundle-builder.liquid`.
2) Else β use a fallback with a thin wrapper section and a logic snippet:
- `sections/bundle-builder.liquid` (wrapper only)
- `snippets/bundle-builder.liquid` (widget logic/UI)
Always confirm product template name, section name, and block type before scaffolding.
Do/Donβt for Online Store builds:
- Do: Put Recharge SDK usage, widget markup, validation, and cart calls in the block.
- Do: Keep the section wrapper βthinβ so the widget can be reused across templates.
- Donβt: Implement the widget as a standalone section with all logic.
Suggested defaults (customizable):
- Template: `product.bundle-builder.json`
- Section (thin wrapper): `bundle-builder.liquid`
- Block (preferred): `bundle-builder` in `blocks/bundle-builder.liquid`
- Fallback (if no `blocks/` directory): keep logic in `snippets/bundle-builder.liquid` and have the thin section render it; document this as temporary and plan to migrate to a block when possible.
Fallback exception (document when used):
- If the theme does not have a `blocks/` directory or cannot render blocks in the desired template, use the thin section + logic snippet fallback. You must:
- Note the limitation.
- Keep logic isolated so it can be moved into a block later.
### Setup Workflow for Online Store
When building a bundle widget for the online store, follow this recommended setup process:
#### 0. Include Required SDK Scripts (must include)
Add these script tags before any code that calls `recharge.*` (theme.liquid
or at the top of your section). Include the Recharge SDK exactly once.
```html
```
#### 1. Confirm Implementation Details
Before creating files, confirm these details with the merchant:
**Required Information:**
- **Product Template Name**: What should the bundle product template be called?
- Default suggestion: `product.bundle-builder.json`
- Example alternatives: `product.bundle.json`, `product.meal-bundle.json`
- **Section Name**: What should the bundle widget section be called?
- Default suggestion: `bundle-builder.liquid`
- Example alternatives: `bundle-widget.liquid`, `meal-bundle-builder.liquid`
- **Block Name**: What should the bundle widget block type be called?
- Default suggestion: `bundle-builder` (keep consistent with the section/template naming)
#### 2. Create Product Template
Create a product template file `templates/product.[template-name].json` that includes the bundle section and a default instance of the bundle block:
```json
{
"sections": {
"main": {
"type": "[section-name]",
"blocks": {
"bundle": { "type": "bundle-builder" }
},
"block_order": ["bundle"],
"settings": {}
}
},
"order": ["main"]
}
```
#### 3. Create Bundle Section (Thin Wrapper Only)
Create a thin wrapper section `sections/[section-name].liquid` that renders its blocks. Keep widget logic inside the block so the widget is embeddable anywhere. The wrapper must not load SDKs, render UI, or make network calls.
Stick to this minimal wrapper pattern and keep the blocks sections as defined here so the merchant can add other block types (no widget logic here):
```liquid
{% content_for 'blocks' %}
{% schema %}
{
"name": "Bundle Builder",
"settings": [
],
"blocks": [
{
"type": "@theme"
},
{
"type": "@app"
}
],
"presets": [
{ "name": "Bundle Builder" }
]
}
{% endschema %}
```
#### 4. Apply Template to Bundle Products
In Shopify Admin:
1. Go to Products β [Your Bundle Product]
2. In the "Theme templates" section, select your new template
3. Save the product
### Theme Block Implementation Notes (Widget Lives Here)
Implement the widget as a theme block (e.g., `blocks/bundle-builder.liquid`). Keep it self-contained and scope styles/IDs per block instance so multiple widgets can coexist. Include the Recharge SDK with an idempotent guard so multiple block instances donβt double-load scripts.
- Product context: use `closest.product` where available; fallback to `product`.
- Mount node: unique per block instance, e.g., `id="bundle-builder-root-{{ block.id }}"`.
- Scoped styles: prefix selectors with a class including `block.id` (e.g., `.bundle-builder--{{ block.id }}`) to avoid collisions.
- Block settings: expose width/alignment and apply via data attributes or CSS variables; include `{{ block.shopify_attributes }}`.
- External scripts: include required SDKs once; optionally add a guard to avoid duplicate loads if multiple blocks render on a page.
Minimal pattern:
```liquid
{% liquid
assign product = closest.product
-%}
{% comment %} ...widget implementation here {% endcomment %}
{% schema %}
{
"name": "Bundle Builder",
"tag": null,
"settings": [
],
"presets": [
{
"name": "Bundle Builder",
"category": "t:categories.product"
}
]
}
{% endschema %}
```
Always implement the block first, then the section, then the product template.
### Using recharge.bundleData.loadBundleData()
This method provides complete bundle data including products and collections in one request:
#### 1. Method Overview
```javascript
// Load complete bundle data including products and collections
const bundleData = await recharge.bundleData.loadBundleData(externalProductId);
```
#### 2. Implementation Pattern
```javascript
async function initializeBundleWidget() {
try {
// Guard early in JS examples too
if (!window.recharge || !recharge.init) {
throw new Error('Recharge SDK not loaded. Include recharge-client script before this code.');
}
// Initialize Recharge SDK
await recharge.init({
storeIdentifier: 'your-store.myshopify.com',
appName: 'Bundle Widget',
appVersion: '1.0.0'
});
// Load complete bundle data (includes products and collections)
const bundleData = await recharge.bundleData.loadBundleData('8138138353916');
console.log('Bundle data loaded:', bundleData);
// Use bundleData to build your widget
} catch (error) {
console.error('Error loading bundle data:', error);
}
}
```
#### Price Normalization (Online Store only)
Prices returned by `recharge.bundleData.loadBundleData()` are in cents. Add this tiny helper and use it wherever you read `variant.price`, `variant.compare_at_price`, or `selling_plan_allocations[*].price` in the Online Store path. GraphQL implementations already use `moneyV2` and do not need this.
```javascript
function normalizePriceFromCents(priceInCents) {
return priceInCents / 100;
}
```
#### 3. Bundle Data Structure
The `loadBundleData()` method returns a `PublicBundleData` object with the following structure:
```typescript
interface PublicBundleData {
/** External product ID (from Shopify) */
id: string;
title: string;
handle: string;
/** Product options (size, color, etc.) */
options: ProductOption[];
default_variant_id: string | null;
/** Whether the bundle is available for purchase */
available_for_sale: boolean;
/** Whether the bundle requires a selling plan (subscription) */
requires_selling_plan: boolean;
bundle_settings: BundleSettings;
variants: BundleVariant[];
selling_plan_groups: SellingPlanGroup[];
/** Collections that contain products for this bundle */
collections: Record;
addons: AddonsSection | null;
cross_sells: CrossSellsSection | null;
incentives: Incentives | null;
}
```
#### 4. Key Components
**Bundle Variants:**
```typescript
interface BundleVariant {
/** External variant ID (from Shopify) */
id: string;
title: string;
price: number;
compare_at_price: number | null;
image: string;
available_for_sale: boolean;
options: string[];
requires_selling_plan: boolean;
selling_plan_allocations: SellingPlanAllocation[];
ranges: QuantityRange[];
collections: CollectionBinding[];
default_selections: DefaultSelection[];
position: number;
}
```
**Quantity Constraints:**
```typescript
interface QuantityRange {
/** Minimum quantity (0 = no minimum) */
min: number;
/** Maximum quantity (null = no maximum) */
max: number | null;
}
interface CollectionBinding extends QuantityRange {
id: string;
source_platform: 'shopify';
}
```
**Collections with Products:**
```typescript
interface Collection {
id: string;
title: string;
handle: string;
products: Product[];
}
interface Product {
/** External product ID */
id: string;
title: string;
description: string | null;
handle: string;
tags: string[];
/** Whether the product is available for sale */
available_for_sale: boolean;
featured_image: string | null;
images: string[];
price_range: PriceRange;
compare_at_price_range: PriceRange;
options: string[];
variants: Variant[];
requires_selling_plan: boolean;
selling_plan_groups: SellingPlanGroup[];
}
```
#### 5. Working with Bundle Data
**Extract Variants:**
```javascript
function populateVariantPicker(bundleData) {
const select = document.getElementById('bundle-variant-select');
bundleData.variants.forEach((variant, index) => {
if (variant.available_for_sale) {
const option = document.createElement('option');
option.value = variant.id;
option.textContent = variant.title || `Bundle Option ${index + 1}`;
select.appendChild(option);
}
});
}
```
**Display Collections:**
```javascript
function displayCollections(variant, bundleData) {
const container = document.getElementById('bundle-collections');
variant.collections.forEach((binding, index) => {
// Get collection data from bundleData.collections
const collectionData = bundleData.collections[binding.id];
if (collectionData && collectionData.products.length > 0) {
const section = document.createElement('div');
section.innerHTML = `
`;
}
```
**Validate Bundle Rules:**
```javascript
function validateBundle(selections, variant) {
const errors = [];
const totalItems = selections.reduce((sum, item) => sum + item.quantity, 0);
// Check bundle size constraints
if (variant.ranges && variant.ranges.length > 0) {
const range = variant.ranges[0];
const minRequired = range.min || 0;
const maxAllowed = range.max || Infinity;
if (totalItems < minRequired) {
errors.push(`Bundle must contain at least ${minRequired} items total`);
}
if (totalItems > maxAllowed) {
errors.push(`Bundle cannot exceed ${maxAllowed} items total`);
}
}
// Check collection constraints
variant.collections.forEach((collection) => {
const collectionItems = selections.filter(item =>
item.collectionId === collection.id
);
const totalFromCollection = collectionItems.reduce((sum, item) => sum + item.quantity, 0);
const minRequired = collection.min || 0;
const maxAllowed = collection.max || Infinity;
if (minRequired > 0 && totalFromCollection < minRequired) {
errors.push(`${collection.id} requires at least ${minRequired} items`);
}
if (maxAllowed < Infinity && totalFromCollection > maxAllowed) {
errors.push(`${collection.id} cannot exceed ${maxAllowed} items`);
}
});
return errors;
}
```
#### 6. Handling Subscription Options (Subscribe & Save)
Bundle products can support both one-time purchases and subscription options. The `loadBundleData()` response includes all necessary information to implement subscription functionality:
**Key Properties for Subscription Detection:**
```typescript
interface PublicBundleData {
// Subscription requirements
requires_selling_plan: boolean; // If true, only subscription purchases allowed
// Available subscription options
selling_plan_groups: SellingPlanGroup[];
// Product variants with selling plan allocations
variants: BundleVariant[];
}
interface SellingPlanGroup {
id: string;
name: string;
merchant_code: string;
app_id: string;
selling_plans: SellingPlan[];
}
interface SellingPlan {
id: string;
name: string; // e.g., "Deliver every 2 weeks", "Monthly delivery"
description: string;
options: SellingPlanOption[];
recurring_deliveries: boolean;
price_adjustments: PriceAdjustment[];
}
```
**Smart Purchase Option Detection:**
```javascript
function analyzeSubscriptionOptions(bundleData) {
// Check if one-time purchases are allowed
const oneTimeAllowed = !bundleData.requires_selling_plan;
// Extract available selling plans
const availableSellingPlans = [];
if (bundleData.selling_plan_groups && bundleData.selling_plan_groups.length > 0) {
bundleData.selling_plan_groups.forEach(group => {
group.selling_plans.forEach(plan => {
availableSellingPlans.push({
id: plan.id,
name: plan.name,
description: plan.description,
options: plan.options || []
});
});
});
}
return { oneTimeAllowed, availableSellingPlans };
}
```
**Subscribe & Save Toggle Implementation:**
```javascript
function setupSubscriptionToggle(bundleData) {
const { oneTimeAllowed, availableSellingPlans } = analyzeSubscriptionOptions(bundleData);
// Show toggle only if BOTH one-time AND subscription are available
const showToggle = oneTimeAllowed && availableSellingPlans.length > 0;
if (showToggle) {
// Show toggle switch for user choice
displaySubscriptionToggle();
} else if (!oneTimeAllowed) {
// Bundle requires subscription - auto-enable
enableSubscriptionMode();
autoSelectFirstSellingPlan();
}
// If only one-time available, no UI needed
}
```
**Dynamic Pricing Based on Selection:**
```javascript
function calculateProductPricing(product, variant, bundleSelections) {
// Online Store bundle data is in cents; normalize to dollars
const oneTimePrice = normalizePriceFromCents(variant.price);
let subscriptionPrice = oneTimePrice;
// If subscription is selected and selling plans exist
if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) {
// Find selling plan allocation for this variant
const sellingPlanAllocation = variant.selling_plan_allocations?.find(
allocation => allocation.selling_plan.id === bundleSelections.selectedSellingPlan.id
);
if (sellingPlanAllocation) {
subscriptionPrice = normalizePriceFromCents(sellingPlanAllocation.price);
}
}
return {
oneTime: oneTimePrice,
subscription: subscriptionPrice,
active: bundleSelections.subscribeAndSave ? subscriptionPrice : oneTimePrice,
isSubscription: bundleSelections.subscribeAndSave
};
}
```
**State Management:**
```javascript
// Use null for one-time, object for subscription
let bundleSelections = {
selectedSellingPlan: null, // null = one-time, object = subscription plan
subscribeAndSave: false,
// ... other properties
};
// Toggle between one-time and subscription
function toggleSubscription(isSubscription) {
bundleSelections.subscribeAndSave = isSubscription;
if (isSubscription && availableSellingPlans.length > 0) {
// Auto-select first plan if none selected
bundleSelections.selectedSellingPlan = bundleSelections.selectedSellingPlan || availableSellingPlans[0];
} else {
// One-time selected
bundleSelections.selectedSellingPlan = null;
}
updateAllProductPricing();
updateBundleSummary();
}
```
**Subscribe & Save Toggle UI Implementation:**
Create a beautiful, prominent toggle that clearly shows subscription benefits:
```html
Subscribe & SaveSelect subscription for savings
```
**Toggle Styling (Professional Switch Design):**
```css
/* Subscribe & Save Toggle */
.subscription-toggle {
background: rgb(var(--color-background, 255 255 255));
border: 2px solid rgb(var(--color-button, 0 123 255));
border-radius: var(--border-radius, 8px);
padding: var(--spacing-lg, 24px);
margin-bottom: var(--spacing-lg, 24px);
box-shadow: 0 2px 8px rgba(var(--color-shadow, 0 0 0), 0.1);
}
.subscription-toggle__content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md, 16px);
padding: var(--spacing-sm, 8px) 0;
}
.subscription-toggle__label {
font-weight: var(--font-weight-bold, 600);
font-size: var(--font-size-lg, 1.1rem);
color: rgb(var(--color-button, 0 123 255));
display: flex;
align-items: center;
gap: var(--spacing-xs, 8px);
}
.subscription-toggle__label::before {
content: "π°";
font-size: 1.2em;
}
/* Toggle Switch Styles */
.subscription-toggle__switch {
position: relative;
display: inline-block;
width: 64px;
height: 32px;
margin: var(--spacing-xs, 4px) 0 0 0;
flex-shrink: 0;
}
.subscription-toggle__switch input {
opacity: 0;
width: 0;
height: 0;
}
.subscription-toggle__slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(145deg, #e2e8f0, #cbd5e0);
transition: all 0.3s ease;
border-radius: 32px;
border: 2px solid #cbd5e0;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.subscription-toggle__slider:before {
position: absolute;
content: '';
height: 24px;
width: 24px;
left: 4px;
bottom: 2px;
background: linear-gradient(145deg, #ffffff, #f1f5f9);
transition: all 0.3s ease;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
border: 1px solid rgba(0,0,0,0.1);
}
.subscription-toggle__switch input:checked + .subscription-toggle__slider {
background: linear-gradient(145deg, rgb(var(--color-button, 0 123 255)), #0066cc);
border-color: rgb(var(--color-button, 0 123 255));
box-shadow: inset 0 2px 4px rgba(0,0,0,0.2), 0 0 0 2px rgba(var(--color-button, 0 123 255), 0.2);
}
.subscription-toggle__switch input:checked + .subscription-toggle__slider:before {
transform: translateX(32px);
background: linear-gradient(145deg, #ffffff, #f8fafc);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* Dynamic Benefit Text */
.subscription-toggle__benefit {
font-size: var(--font-size-sm, 0.9rem);
color: rgb(var(--color-foreground-secondary, 102 102 102));
font-weight: var(--font-weight-normal, 400);
font-style: italic;
}
```
**Dynamic Benefit Text Implementation:**
```javascript
// Update subscription benefit text with dynamic discount info
function updateSubscriptionBenefitText() {
const benefitText = document.getElementById('subscription-benefit-text');
if (!benefitText) return;
if (availableSellingPlans.length > 0) {
// Find the largest discount percentage and frequency
let maxDiscount = 0;
let frequency = '';
availableSellingPlans.forEach(plan => {
// Extract frequency from plan options
if (plan.options && plan.options.length > 0) {
plan.options.forEach(option => {
if (option.name === 'Order Frequency and Unit' && option.values && option.values.length > 0) {
frequency = option.values[0].replace('-', ' ');
}
});
}
// Find price adjustments in bundle data
if (bundleData && bundleData.selling_plan_groups) {
bundleData.selling_plan_groups.forEach(group => {
if (group.selling_plans) {
group.selling_plans.forEach(sp => {
if (sp.id.toString() === plan.id.toString() && sp.price_adjustments && sp.price_adjustments.length > 0) {
sp.price_adjustments.forEach(adjustment => {
if (adjustment.value_type === 'percentage' && adjustment.value > maxDiscount) {
maxDiscount = adjustment.value;
}
});
}
});
}
});
}
});
if (maxDiscount > 0) {
benefitText.textContent = `Get ${maxDiscount}% off with ${frequency || 'subscription'} delivery`;
} else {
benefitText.textContent = `Save with ${frequency || 'subscription'} delivery`;
}
} else {
benefitText.textContent = 'Select subscription for savings';
}
}
```
**Critical: Selling Plan Matching for Dynamic vs Fixed Bundles**
There's a crucial distinction between fixed priced bundles and dynamically priced bundles when handling selling plans:
**Fixed Priced Bundles:**
- Use the selling plan from the bundle product level
- All items inherit the same selling plan ID
- Simple implementation: apply bundle product's selling plan to all selections
**Dynamic Priced Bundles:**
- Each child product may have different selling plan IDs
- Must find equivalent selling plans that match the bundle product's semantics
- Match based on "Order Frequency and Unit" and price adjustment values
- **Required for this implementation** since we're building dynamic bundle widgets
**Why This Matters:**
If you simply use the bundle product's selling plan ID for all child items, the cart may fail or apply incorrect pricing/discounts to individual products. Each child product must use its own selling plan ID that has the same subscription terms (frequency and discount) as the bundle product.
**Implementation Requirement:**
This selling plan matching logic is **essential** for dynamic bundles where:
- Products come from different collections
- Each product has its own set of selling plans
- The bundle product acts as a template for subscription terms
**Enhanced Selling Plan Matching Logic:**
```javascript
// Find matching selling plan for child product using two-step approach
function findMatchingSellingPlan(bundleSellingPlan, childProductSellingPlans, productVariants = []) {
if (!bundleSellingPlan || !childProductSellingPlans || childProductSellingPlans.length === 0) {
return null;
}
// STEP 1: Try direct selling_plan_allocation match first (most accurate)
// Check if any product variants have selling_plan_allocations that directly match the bundle selling plan ID
if (productVariants && productVariants.length > 0) {
for (const variant of productVariants) {
if (variant.selling_plan_allocations) {
const directMatch = variant.selling_plan_allocations.find(
allocation => allocation.selling_plan.id === bundleSellingPlan.id
);
if (directMatch) {
console.log(`β Direct selling_plan_allocation match found for selling plan ${bundleSellingPlan.id}`);
return bundleSellingPlan; // Return the bundle selling plan since it's directly supported
}
}
}
}
console.log(`β οΈ No direct selling_plan_allocation match for ${bundleSellingPlan.id}, falling back to heuristic matching`);
// STEP 2: Fall back to heuristic matching (frequency + price adjustment)
// Extract criteria from bundle selling plan
const bundleFrequency = bundleSellingPlan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value;
const bundlePriceAdjustment = bundleSellingPlan.price_adjustments?.[0];
if (!bundleFrequency || !bundlePriceAdjustment) {
console.log(`β Bundle selling plan missing frequency or price adjustment data`);
return null;
}
// Find matching child selling plan with same frequency and discount
const matchingPlan = childProductSellingPlans.find(childPlan => {
// Check frequency match (e.g., "1-week")
const childFrequency = childPlan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value;
if (childFrequency !== bundleFrequency) return false;
// Check price adjustment match (e.g., percentage: 20%)
const childPriceAdjustment = childPlan.price_adjustments?.[0];
if (!childPriceAdjustment || !bundlePriceAdjustment) return false;
return (
childPriceAdjustment.value_type === bundlePriceAdjustment.value_type &&
childPriceAdjustment.value === bundlePriceAdjustment.value
);
});
if (matchingPlan) {
console.log(`β Heuristic match found: ${matchingPlan.id} for bundle plan ${bundleSellingPlan.id}`);
} else {
console.log(`β No heuristic match found for bundle plan ${bundleSellingPlan.id}`);
}
return matchingPlan;
}
// Get selling plan data from bundle data
function getBundleSellingPlanData(sellingPlanId) {
if (!bundleData || !bundleData.selling_plan_groups) return null;
for (const group of bundleData.selling_plan_groups) {
if (group.selling_plans) {
const plan = group.selling_plans.find(sp => sp.id.toString() === sellingPlanId.toString());
if (plan) return plan;
}
}
return null;
}
// Get child product selling plans from bundle data
function getChildProductSellingPlans(productId) {
if (!bundleData || !bundleData.collections) return [];
// Find the product in collections and get its selling plan groups
for (const collectionId in bundleData.collections) {
const collection = bundleData.collections[collectionId];
if (collection.products) {
const product = collection.products.find(p => p.id === productId);
if (product && product.selling_plan_groups) {
// Extract all selling plans from all groups
const allSellingPlans = [];
product.selling_plan_groups.forEach(group => {
if (group.selling_plans) {
allSellingPlans.push(...group.selling_plans);
}
});
return allSellingPlans;
}
}
}
return [];
}
// Get child product variants from bundle data (needed for selling_plan_allocations check)
function getChildProductVariants(productId) {
if (!bundleData || !bundleData.collections) return [];
// Find the product in collections and get its variants
for (const collectionId in bundleData.collections) {
const collection = bundleData.collections[collectionId];
if (collection.products) {
const product = collection.products.find(p => p.id === productId);
if (product && product.variants) {
return product.variants;
}
}
}
return [];
}
```
### Benefits of the Enhanced Two-Step Approach
**Why This Approach is Superior:**
1. **Direct Allocation Priority**: First checks if the child product already has a `selling_plan_allocation` for the exact bundle selling plan ID - this is the most accurate match possible
2. **Fallback Security**: If no direct allocation exists, falls back to heuristic matching based on frequency and discount semantics
3. **Performance**: Direct ID matching is faster than semantic comparison
4. **Accuracy**: Eliminates potential mismatches from semantic interpretation differences
5. **Future-Proof**: Works with any selling plan structure that Shopify/Recharge might introduce
### Simplified Approach for Direct ID Matching (Recommended)
The **simplified direct ID matching approach** is recommended over complex heuristic matching:
1. **Product Filtering**: Only show products that have `selling_plan_allocations` with IDs that match bundle selling plan IDs
2. **Cart Integration**: Use the bundle selling plan ID directly for all compatible products
3. **Eliminates Edge Cases**: Removes products with different discount percentages or incompatible terms
4. **Simpler Implementation**: Reduces complexity and potential bugs from semantic matching
5. **Better User Experience**: Users only see products that definitely work with the bundle
**When to Use Each Approach:**
- **Simplified Direct Matching**: When all compatible products share the same selling plan IDs as the bundle (most common)
- **Enhanced Two-Step**: When products have different selling plan IDs but equivalent semantics (frequency + discount)
**Expected Debug Output Patterns:**
**Success with Direct Match:**
```
β Direct selling_plan_allocation match found for selling plan 4956258556
Found matching selling plan for product 7823764455676: {
bundlePlan: 4956258556,
childPlan: 4956258556,
method: "direct_allocation",
frequency: "1-week"
}
```
**Success with Heuristic Fallback:**
```
β οΈ No direct selling_plan_allocation match for 4956258556, falling back to heuristic matching
β Heuristic match found: 4864737532 for bundle plan 4956258556
Found matching selling plan for product 7823764455676: {
bundlePlan: 4956258556,
childPlan: 4864737532,
method: "heuristic_match",
frequency: "1-week"
}
```
**Failure (Both Methods):**
```
β οΈ No direct selling_plan_allocation match for 4956258556, falling back to heuristic matching
β No heuristic match found for bundle plan 4956258556
No matching selling plan found for product 7823764455676, falling back to bundle selling plan
```
**Cart Integration with Intelligent Selling Plan Matching:**
```javascript
async function addBundleToCart(bundleSelections) {
const bundleProductData = {
productId: bundleProductId,
variantId: bundleSelections.variant.id,
handle: bundleProductHandle
};
// Include selling plan if subscription is selected
if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) {
bundleProductData.sellingPlan = bundleSelections.selectedSellingPlan.id;
}
const bundle = {
externalVariantId: bundleSelections.variant.id,
externalProductId: bundleProductId,
selections: bundleSelections.items.map(item => {
const selection = {
collectionId: item.collectionId,
externalProductId: item.productId,
externalVariantId: item.variantId,
quantity: item.quantity
};
// For dynamic bundles, find matching selling plan for each child item
if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) {
// Get the bundle selling plan data
const bundleSellingPlanData = getBundleSellingPlanData(bundleSelections.selectedSellingPlan.id);
if (bundleSellingPlanData) {
// Get child product's available selling plans and variants
const childSellingPlans = getChildProductSellingPlans(item.productId);
const childProductVariants = getChildProductVariants(item.productId);
// Find matching selling plan using enhanced two-step approach
const matchingSellingPlan = findMatchingSellingPlan(
bundleSellingPlanData,
childSellingPlans,
childProductVariants
);
if (matchingSellingPlan) {
selection.sellingPlan = matchingSellingPlan.id;
console.log(`Found matching selling plan for product ${item.productId}:`, {
bundlePlan: bundleSellingPlanData.id,
childPlan: matchingSellingPlan.id,
method: matchingSellingPlan.id === bundleSellingPlanData.id ? 'direct_allocation' : 'heuristic_match',
frequency: bundleSellingPlanData.options?.find(opt => opt.name === "Order Frequency and Unit")?.value
});
} else {
console.warn(`No matching selling plan found for product ${item.productId}, falling back to bundle selling plan`);
selection.sellingPlan = bundleSelections.selectedSellingPlan.id;
}
} else {
// Fallback to bundle selling plan if no data found
selection.sellingPlan = bundleSelections.selectedSellingPlan.id;
}
}
return selection;
})
};
// Get cart items from Recharge SDK (handles selling plans automatically)
const cartItems = await recharge.bundle.getDynamicBundleItems(bundle, bundleProductData.handle);
// Add to cart using Ajax Cart API
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: cartItems })
});
}
```
**Example Selling Plan Matching:**
If the bundle product has this selling plan:
```json
{
"id": 4956258556,
"name": "1 week subscription with 20% discount",
"options": [
{
"name": "Order Frequency and Unit",
"value": "1-week"
}
],
"price_adjustments": [
{
"value_type": "percentage",
"value": 20
}
]
}
```
The function will look for child product selling plans with:
- β Same frequency: `"1-week"`
- β Same discount: `percentage: 20`
And select the matching selling plan ID, even if it's different (e.g., `4833706236` vs `4956258556`).
**β οΈ Important: Debugging Selling Plan Matching**
When implementing selling plan matching, it's crucial to verify that the logic works correctly. A common bug occurs when child products get assigned selling plans with different frequencies than the bundle product.
**Debugging Strategy:**
1. **Add comprehensive logging** to track selling plan selection:
```javascript
console.log(`π DEBUGGING: findMatchingSellingPlan for product ${productId}`);
console.log(`π― Bundle criteria to match:`, {
bundleSellingPlanId: bundleSellingPlan.id,
bundleFrequency,
bundlePriceAdjustment
});
```
2. **Prevent cart submission** during debugging:
```javascript
// π¨ DEBUG MODE: Log bundle data and stop before cart submission
console.log('π¨ DEBUG MODE: Stopping before cart submission to debug selling plans');
console.log('π¦ Final bundle object being sent to Recharge SDK:', JSON.stringify(bundle, null, 2));
// π STOP HERE - Don't actually add to cart in debug mode
console.log('π STOPPING: Debug mode active - cart submission prevented');
return;
```
3. **Validate cart results** by checking the actual selling plans assigned:
```javascript
rechargeCartItems.forEach((item, index) => {
console.log(`Cart item ${index + 1}:`, {
variantId: item.id,
sellingPlanId: item.selling_plan_allocation?.selling_plan?.id,
frequency: item.selling_plan_allocation?.selling_plan?.options?.find(opt => opt.name === "Order Frequency and Unit")?.value
});
});
```
**Example Bug:**
- User selects: "1 week subscription"
- Expected result: All items should have selling plans with `"1-week"` frequency
- Actual bug: Some items get `"2-week"` frequency instead
- Root cause: Matching logic fails to find correct selling plans for some products
**Fix Process:**
1. Add debug logging to identify which products are failing to match
2. Verify that child products actually have the required selling plans available
3. Check that matching criteria (frequency + price adjustment) are being compared correctly
4. Ensure fallback logic doesn't inadvertently assign wrong selling plans
**Important: Product Filtering for Selling Plan Compatibility**
To ensure a consistent user experience, the bundle widget should only display products that have compatible selling plans with the bundle product.
**Filtering Logic:**
```javascript
// Get bundle selling plan requirements (all selling plans the bundle supports)
function getBundleSellingPlanRequirements() {
if (!bundleData || !bundleData.selling_plan_groups) return [];
const allBundleSellingPlans = [];
bundleData.selling_plan_groups.forEach(group => {
if (group.selling_plans) {
allBundleSellingPlans.push(...group.selling_plans);
}
});
// Extract the frequency and discount requirements
const requirements = allBundleSellingPlans.map(plan => {
const frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value;
const priceAdjustment = plan.price_adjustments?.[0];
return {
id: plan.id,
name: plan.name,
frequency,
priceAdjustment: priceAdjustment ? {
value_type: priceAdjustment.value_type,
value: priceAdjustment.value
} : null
};
}).filter(req => req.frequency && req.priceAdjustment); // Only include complete requirements
return requirements;
}
// Check if a product has compatible selling plans with the bundle
function hasCompatibleSellingPlans(product, bundleRequirements) {
if (!product.selling_plan_groups || bundleRequirements.length === 0) {
return false;
}
// Get all selling plans for this product
const productSellingPlans = [];
product.selling_plan_groups.forEach(group => {
if (group.selling_plans) {
productSellingPlans.push(...group.selling_plans);
}
});
// Check if product has ALL required selling plans
const compatibleCount = bundleRequirements.filter(requirement => {
return productSellingPlans.find(plan => {
const frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value;
const priceAdjustment = plan.price_adjustments?.[0];
return frequency === requirement.frequency &&
priceAdjustment?.value_type === requirement.priceAdjustment?.value_type &&
priceAdjustment?.value === requirement.priceAdjustment?.value;
});
}).length;
return compatibleCount === bundleRequirements.length;
}
```
**Example Filtering Scenarios:**
**Bundle Product Selling Plans:**
- 1-week subscription with 20% discount
- 2-week subscription with 15% discount
**Product A (β Compatible):**
- 1-week subscription with 20% discount
- 2-week subscription with 15% discount
- 3-week subscription with 10% discount
β **Shows in grid** (has all required plans plus additional)
**Product B (β Incompatible):**
- 1-week subscription with 20% discount
- 3-week subscription with 10% discount
β **Hidden from grid** (missing 2-week 15% discount)
**Product C (β Incompatible):**
- 1-week subscription with 30% discount
- 2-week subscription with 15% discount
β **Hidden from grid** (1-week discount doesn't match)
**Implementation in Product Display:**
```javascript
// Filter products before displaying
const bundleRequirements = getBundleSellingPlanRequirements();
const compatibleProducts = collectionData.products.filter(product => {
return hasCompatibleSellingPlans(product, bundleRequirements);
});
// Only display compatible products
if (compatibleProducts.length > 0) {
// Show filtered products
} else {
// Show empty collection message
}
```
**Why This Matters:**
1. **Prevents Subscription Failures**: Users can't select products that don't support their chosen subscription frequency
2. **Consistent Experience**: All products in a bundle will have the same subscription options available
3. **Clear User Communication**: Users understand why certain products aren't available
4. **Reduces Support Issues**: Eliminates cart failures due to incompatible selling plans
**Complete Toggle Logic Implementation:**
```javascript
// Setup subscription toggle functionality
function setupSubscriptionToggle() {
const subscriptionToggle = document.getElementById('subscription-toggle');
const subscribeCheckbox = document.getElementById('subscribe-save-checkbox');
const sellingPlanSelector = document.getElementById('selling-plan-selector');
const sellingPlanSelect = document.getElementById('selling-plan-select');
// Only show toggle if both one-time and subscription options are available
const showToggle = oneTimeAllowed && availableSellingPlans.length > 0;
if (showToggle) {
// Show toggle for user choice
subscriptionToggle.style.display = 'block';
subscribeCheckbox.disabled = false;
sellingPlanSelector.style.display = 'none'; // Initially hidden until checkbox is checked
populateSellingPlanOptions();
} else if (!oneTimeAllowed && availableSellingPlans.length > 0) {
// Bundle requires subscription - auto-enable and show toggle
subscriptionToggle.style.display = 'block';
subscribeCheckbox.checked = true;
subscribeCheckbox.disabled = true;
bundleSelections.subscribeAndSave = true;
sellingPlanSelector.style.display = 'block';
populateSellingPlanOptions();
// Auto-select first selling plan
if (availableSellingPlans.length > 0) {
bundleSelections.selectedSellingPlan = availableSellingPlans[0];
sellingPlanSelect.value = availableSellingPlans[0].id;
}
} else {
// No subscription options available - hide toggle
subscriptionToggle.style.display = 'none';
}
// Subscribe checkbox event listener
subscribeCheckbox.addEventListener('change', function() {
bundleSelections.subscribeAndSave = this.checked;
if (this.checked) {
sellingPlanSelector.style.display = 'block';
// Auto-select first selling plan if none selected
if (!bundleSelections.selectedSellingPlan && availableSellingPlans.length > 0) {
bundleSelections.selectedSellingPlan = availableSellingPlans[0];
sellingPlanSelect.value = availableSellingPlans[0].id;
}
} else {
sellingPlanSelector.style.display = 'none';
bundleSelections.selectedSellingPlan = null;
sellingPlanSelect.value = '';
}
// Update pricing and summary
updateAllProductPricing();
updateBundleSummary();
});
// Selling plan select event listener
sellingPlanSelect.addEventListener('change', function() {
const selectedPlanId = this.value;
if (selectedPlanId) {
bundleSelections.selectedSellingPlan = availableSellingPlans.find(plan => plan.id === selectedPlanId);
} else {
bundleSelections.selectedSellingPlan = null;
}
// Update pricing and summary
updateAllProductPricing();
updateBundleSummary();
});
}
// Populate selling plan options in dropdown
function populateSellingPlanOptions() {
const sellingPlanSelect = document.getElementById('selling-plan-select');
if (!sellingPlanSelect) {
console.error('selling-plan-select element not found!');
return;
}
// Clear existing options (except the default)
sellingPlanSelect.innerHTML = '';
availableSellingPlans.forEach(plan => {
const option = document.createElement('option');
option.value = plan.id;
option.textContent = plan.name;
if (plan.description) {
option.title = plan.description;
}
sellingPlanSelect.appendChild(option);
});
}
```
**Smart State Management:**
```javascript
// State management - use null for one-time, object for subscription
let bundleSelections = {
selectedSellingPlan: null, // null = one-time, object = subscription plan
subscribeAndSave: false,
// ... other properties
};
// Toggle between one-time and subscription
function toggleSubscription(isSubscription) {
bundleSelections.subscribeAndSave = isSubscription;
if (isSubscription && availableSellingPlans.length > 0) {
// Auto-select first plan if none selected
bundleSelections.selectedSellingPlan = bundleSelections.selectedSellingPlan || availableSellingPlans[0];
} else {
// One-time selected
bundleSelections.selectedSellingPlan = null;
}
updateAllProductPricing();
updateBundleSummary();
}
```
**Dynamic Product Pricing with Subscriptions:**
```javascript
// Calculate pricing based on subscription selection
function calculateProductPricing(product, variant, bundleSelections) {
const oneTimePrice = variant.price;
let subscriptionPrice = oneTimePrice;
// If subscription is selected and selling plans exist
if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) {
// Find selling plan allocation for this variant
const sellingPlanAllocation = variant.selling_plan_allocations?.find(
allocation => allocation.selling_plan.id === bundleSelections.selectedSellingPlan.id
);
if (sellingPlanAllocation) {
subscriptionPrice = sellingPlanAllocation.price;
}
}
return {
oneTime: oneTimePrice,
subscription: subscriptionPrice,
active: bundleSelections.subscribeAndSave ? subscriptionPrice : oneTimePrice,
isSubscription: bundleSelections.subscribeAndSave
};
}
// Update all product cards with current pricing
function updateAllProductPricing() {
// Find all product cards and update their pricing displays
const root = document.getElementById('bundleWidget');
const priceCompareClass = root?.dataset?.priceCompareClass || 'price-compare-at';
document.querySelectorAll('.bundle-product-card').forEach(card => {
const productId = card.dataset.productId;
const variantId = card.dataset.variantId;
// Find product and variant data
const product = findProductInCollections(productId);
const variant = product?.variants.find(v => v.id === variantId);
if (product && variant) {
const pricing = calculateProductPricing(product, variant, bundleSelections);
// Update price display
const priceElement = card.querySelector('.bundle-product-price');
if (priceElement) {
priceElement.innerHTML = `
${pricing.isSubscription && pricing.subscription !== pricing.oneTime ?
`$${pricing.oneTime.toFixed(2)}` : ''
}
$${pricing.active.toFixed(2)}
${pricing.isSubscription ? '/ delivery' : ''}
`;
}
}
});
}
```
**Benefits of This Approach:**
- β **Smart Detection**: Automatically detects available purchase options
- β **Flexible UI**: Shows toggle only when both options available
- β **Auto-Configuration**: Handles subscription-required bundles automatically
- β **Dynamic Pricing**: Shows correct prices based on selection
- β **Professional Design**: Beautiful toggle switch with clear visual feedback
- β **Accessible**: Proper ARIA labels and keyboard navigation
- β **Proper Integration**: Works seamlessly with Recharge SDK
#### 7. Complete Implementation Example
```javascript
document.addEventListener('DOMContentLoaded', async function() {
const CONFIG = {
storeIdentifier: 'your-store.myshopify.com',
bundleProductId: 'gid://shopify/Product/8138138353916', // From closest.product
appName: 'Bundle Widget',
appVersion: '1.0.0'
};
let bundleData = null;
let bundleSelections = {
variantId: null,
variant: null,
items: [],
totalItems: 0,
isValid: false,
errors: []
};
async function initializeBundleWidget() {
try {
// Initialize Recharge SDK
await recharge.init(CONFIG);
// Load complete bundle data
bundleData = await recharge.bundleData.loadBundleData(CONFIG.bundleProductId);
if (!bundleData || !bundleData.variants || bundleData.variants.length === 0) {
throw new Error('No bundle variants found');
}
// Setup widget
populateVariantPicker(bundleData);
// Auto-select first variant
if (bundleData.variants.length === 1) {
selectVariant(bundleData.variants[0]);
}
} catch (error) {
console.error('Error initializing bundle widget:', error);
}
}
function selectVariant(variant) {
bundleSelections.variant = variant;
bundleSelections.variantId = variant.id;
// Display collections for this variant
displayCollections(variant, bundleData);
}
// Start the widget
initializeBundleWidget();
});
```
#### 7. Benefits of loadBundleData()
1. **Single API Call**: All data in one request, no need for multiple GraphQL queries
2. **Optimized Performance**: Faster loading compared to separate collection fetches
3. **Complete Data**: Includes products, variants, images, pricing, and constraints
4. **Simplified Code**: No need for Shopify Storefront API client or access tokens
5. **Real-time Data**: Always returns current product availability and pricing
6. **Built-in Optimization**: Cached and optimized by Recharge infrastructure
### Key Features
1. **Online Store Optimized**: Specifically designed for Shopify theme implementations
2. **Product Context**: Use `closest.product.id` when implementing in Shopify blocks
3. **No Access Tokens**: No need for Storefront API access tokens
4. **Real-time Data**: Product availability and pricing are always current
5. **Single Request**: All data loaded in one optimized call
6. **Error Handling**: Built-in error handling and retry mechanisms
7. **Subscription Support**: Built-in support for one-time and subscription purchases with selling plans
**Note for GraphQL/Headless Implementations:**
The subscription handling patterns documented above can also be applied to GraphQL implementations by:
- Using `recharge.cdn.getCDNBundleSettings()` to get selling plan information
- Querying Shopify Storefront API for selling plan allocations on product variants
- Following the same smart detection and state management patterns
- Including selling plans in the cart integration as shown above
This approach simplifies bundle widget development for Shopify online stores while providing excellent performance and reliable data access.
## β‘ REQUIRED: Building Bundle Widgets for Headless/Custom Implementations
For headless implementations, custom storefronts, or when you need maximum flexibility in data handling, use the GraphQL approach with Recharge CDN settings.
### Setup Workflow for Headless/Custom
When building a bundle widget for headless or custom implementations, follow this setup process:
#### 1. Gather Required Configuration
Before implementing the widget, collect these essential details from the merchant:
**Required Information:**
- **Bundle Type to Implement**: Choose one
- Fixed Price (single bundle line item; add ONLY bundle product with `_rb_id`)
- Dynamic (individual items added; price sums by selections)
- **Store Domain**: The full Shopify store domain
- Example: `your-store.myshopify.com`
- Format: `https://your-store.myshopify.com` (include https://)
- **Storefront Access Token**: Shopify Storefront API access token
- Example: `shpat_xxxxxx`
- Required scope: Storefront API access with product and collection read permissions
- **Bundle Product ID**: The Shopify product ID for the bundle
- Example: `gid://shopify/Product/8138138353916` or just `8138138353916`
**Example Questions to Ask:**
```
"I'll create a bundle widget for your custom implementation. I need these details:
First, which bundle type should this widget support?
- Fixed Price (add only the bundle product with _rb_id)
- Dynamic (add individual child items)
If you're not sure, I can auto-detect after loading the bundle settings.
1. Your Shopify store domain (e.g., your-store.myshopify.com)
2. A Shopify Storefront API access token (starts with 'shpat_')
3. The product ID of your bundle product
Do you have a Storefront API access token set up? If not, I can guide you through creating one."
```
#### 2. Access Token Setup Guide
If the merchant needs to create a Storefront API access token:
1. **Go to Shopify Admin** β Apps β App development β Private apps
2. **Create a private app** (or use existing one)
3. **Enable Storefront API access**
4. **Configure permissions**:
- Read products, variants, and inventory
- Read collections
- Read metafields (if using custom metafields)
5. **Copy the Storefront access token**
#### 3. Configuration Structure
Ensure your widget implementation includes easily configurable settings:
```javascript
// Configuration object - make this easily customizable
const CONFIG = {
// Store configuration
storeDomain: 'https://your-store.myshopify.com', // CUSTOMIZE: Replace with actual store domain
storeIdentifier: 'your-store.myshopify.com', // CUSTOMIZE: Replace with store domain (without https)
// API configuration
storefrontAccessToken: 'shpat_your-token-here', // CUSTOMIZE: Replace with actual access token
// Bundle configuration
bundleProductId: '8138138353916', // CUSTOMIZE: Replace with actual bundle product ID
// App configuration
appName: 'Bundle Widget',
appVersion: '1.0.0',
apiVersion: '2024-10'
};
// Validation function
function validateConfig() {
const errors = [];
if (!CONFIG.storeDomain || CONFIG.storeDomain.includes('your-store')) {
errors.push('Store domain must be configured');
}
if (!CONFIG.storefrontAccessToken || CONFIG.storefrontAccessToken.includes('your-token')) {
errors.push('Storefront access token must be configured');
}
if (!CONFIG.bundleProductId || CONFIG.bundleProductId === '8138138353916') {
errors.push('Bundle product ID must be configured');
}
if (errors.length > 0) {
console.error('Configuration errors:', errors);
throw new Error('Widget configuration incomplete: ' + errors.join(', '));
}
}
```
#### 4. Environment-based Configuration
For production applications, use environment variables:
```javascript
// Environment-based configuration
const CONFIG = {
storeDomain: process.env.SHOPIFY_STORE_DOMAIN || 'https://your-store.myshopify.com',
storeIdentifier: process.env.SHOPIFY_STORE_DOMAIN?.replace('https://', '') || 'your-store.myshopify.com',
storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_TOKEN || 'shpat_your-token-here',
bundleProductId: process.env.BUNDLE_PRODUCT_ID || '8138138353916',
appName: 'Bundle Widget',
appVersion: '1.0.0'
};
```
### Using GraphQL with recharge.cdn.getCDNBundleSettings()
This approach provides maximum flexibility by separating bundle configuration from product data loading:
#### 1. Method Overview
```javascript
// Get bundle configuration from Recharge
const bundleSettings = await recharge.cdn.getCDNBundleSettings(externalProductId);
// Use GraphQL to fetch product data from collections
const collectionData = await shopifyClient.request(COLLECTION_QUERY, {
variables: { id: `gid://shopify/Collection/${collectionId}` }
});
```
#### 5. Implementation Pattern
```javascript
async function initializeBundleWidget() {
try {
// Validate configuration before starting
validateConfig();
// Initialize Recharge SDK
await recharge.init({
storeIdentifier: CONFIG.storeIdentifier,
appName: CONFIG.appName,
appVersion: CONFIG.appVersion
});
// Initialize Shopify Storefront client
const shopifyClient = ShopifyStorefrontAPIClient.createStorefrontApiClient({
storeDomain: CONFIG.storeDomain,
apiVersion: CONFIG.apiVersion,
publicAccessToken: CONFIG.storefrontAccessToken
});
// Get bundle settings from Recharge
const bundleSettings = await recharge.cdn.getCDNBundleSettings(CONFIG.bundleProductId);
// Process bundle variants and extract collection IDs
const variantCollections = getVariantCollections(bundleSettings);
// Load products from collections using GraphQL
for (const variant of variantCollections) {
for (const collection of variant.collections) {
const collectionData = await fetchCollectionProducts(collection.collectionId, shopifyClient);
// Process collection data...
}
}
} catch (error) {
console.error('Error loading bundle data:', error);
// Show user-friendly error message
showConfigurationError(error.message);
}
}
// Helper function to show configuration errors
function showConfigurationError(message) {
const errorContainer = document.getElementById('bundle-error') || document.body;
errorContainer.innerHTML = `
Configuration Error
${message}
Please check your widget configuration and try again.
`;
}
```
#### 6. Bundle Settings Structure
The `getCDNBundleSettings()` method returns bundle configuration with this structure:
```javascript
// Example bundle settings structure
{
"external_product_id": "8138138353916",
"variants": [
{
"id": 32967,
"external_variant_id": "44636069724412",
"enabled": true,
"items_count": 2,
"option_sources": [
{
"id": 403608,
"collection_id": "400595910908",
"quantity_min": null,
"quantity_max": null
}
],
"ranges": [
{
"id": 1298,
"quantity_min": 1,
"quantity_max": null
}
],
"selection_defaults": []
}
]
}
```
#### 7. GraphQL Collection Query
Use this enhanced GraphQL query to fetch collection products with pagination support:
```graphql
query getCollectionWithMetafields($id: ID!, $first: Int!, $after: String) {
collection(id: $id) {
id
title
description
products(first: $first, after: $after) {
edges {
node {
id
title
description
images(first: 5) {
edges {
node {
url
altText
}
}
}
variants(first: 100) {
edges {
node {
id
price {
amount
currencyCode
}
availableForSale
}
}
}
# Add metafields for enhanced product data
dietaryPreferences: metafield(namespace: "shopify", key: "dietary-preferences") {
id
type
references(first: 10) {
nodes {
... on Metaobject {
id
fields {
key
value
}
}
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
```
#### 8. Pagination Handling
For large collections, implement pagination to load all products:
```javascript
async function fetchAllProductsFromCollection(collectionId, shopifyClient) {
const collectionGid = `gid://shopify/Collection/${collectionId}`;
let allProducts = [];
let hasNextPage = true;
let cursor = null;
while (hasNextPage) {
const response = await shopifyClient.request(COLLECTION_QUERY, {
variables: {
id: collectionGid,
first: 250, // Maximum allowed per request
after: cursor
}
});
const collection = response.data?.collection;
if (!collection) break;
// Add products from this page
const products = collection.products.edges || [];
allProducts = allProducts.concat(products);
// Check pagination
const pageInfo = collection.products.pageInfo;
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
}
return {
...collection,
products: { edges: allProducts }
};
}
```
#### 9. Bundle Rules Processing
Extract and process bundle rules from settings:
```javascript
function getVariantCollections(bundleSettings) {
if (!bundleSettings || !bundleSettings.variants) {
return [];
}
return bundleSettings.variants.map(variant => ({
variantId: variant.id,
externalVariantId: variant.external_variant_id,
enabled: variant.enabled,
itemsCount: variant.items_count,
collections: variant.option_sources.map(source => ({
optionSourceId: source.id,
collectionId: source.collection_id,
quantityMin: source.quantity_min,
quantityMax: source.quantity_max
})),
ranges: variant.ranges,
defaults: variant.selection_defaults
}));
}
```
#### 10. Validation Implementation
Implement bundle validation using the extracted rules:
```javascript
function validateBundleSelections(selections, variant) {
const errors = [];
const totalItems = selections.reduce((sum, item) => sum + item.quantity, 0);
// Check bundle size constraints
if (variant.ranges && variant.ranges.length > 0) {
const range = variant.ranges[0];
const minRequired = range.quantity_min || 0;
const maxAllowed = range.quantity_max || Infinity;
if (totalItems < minRequired) {
errors.push(`Bundle must contain at least ${minRequired} items total`);
}
if (totalItems > maxAllowed) {
errors.push(`Bundle cannot exceed ${maxAllowed} items total`);
}
}
// Check collection constraints
variant.collections.forEach((collection, index) => {
const collectionItems = selections.filter(item =>
item.collectionId === collection.collectionId
);
const totalFromCollection = collectionItems.reduce((sum, item) => sum + item.quantity, 0);
const minRequired = collection.quantityMin || 0;
const maxAllowed = collection.quantityMax || Infinity;
if (minRequired > 0 && totalFromCollection < minRequired) {
errors.push(`Collection ${index + 1} requires at least ${minRequired} items`);
}
if (maxAllowed < Infinity && totalFromCollection > maxAllowed) {
errors.push(`Collection ${index + 1} cannot exceed ${maxAllowed} items`);
}
});
return errors;
}
```
### Benefits of GraphQL Approach
1. **Maximum Flexibility**: Full control over data queries and processing
2. **Custom Metafields**: Access to product metafields for rich data
3. **Caching Control**: Implement custom caching strategies
4. **Filtering Options**: Add custom product filtering and search
5. **Third-party Integration**: Easy integration with external systems
6. **Mobile Optimization**: Optimize queries for mobile performance
7. **Advanced Features**: Support for complex product relationships
### Key Features
1. **Headless Optimized**: Perfect for custom storefronts and mobile apps
2. **Flexible Data Access**: Query exactly the data you need
3. **Metafield Support**: Access rich product metadata
4. **Custom Caching**: Implement optimized caching strategies
5. **Advanced Pagination**: Handle large product collections efficiently
6. **Third-party Ready**: Easy integration with external systems
This approach provides maximum flexibility for custom implementations while maintaining full compatibility with Recharge bundle functionality.
## β‘ REQUIRED: Extracting Collections from Bundle Settings
### Get Collections for Each Variant
Bundle variants contain `option_sources` which represent Shopify collections that contain the available products for that variant.
```javascript
/**
* Extracts collections associated with each bundle variant
* @param {Object} bundleSettings - The bundle settings from getCDNBundleSettings
* @returns {Array} Array of variant objects with their associated collections
*/
function getVariantCollections(bundleSettings) {
if (!bundleSettings || !bundleSettings.variants) {
return [];
}
return bundleSettings.variants.map(variant => ({
variantId: variant.id,
externalVariantId: variant.external_variant_id,
enabled: variant.enabled,
itemsCount: variant.items_count,
collections: variant.option_sources.map(source => ({
optionSourceId: source.id,
collectionId: source.collection_id,
quantityMin: source.quantity_min,
quantityMax: source.quantity_max
})),
ranges: variant.ranges,
defaults: variant.selection_defaults
}));
}
/**
* Get all unique collection IDs from bundle settings
* @param {Object} bundleSettings - The bundle settings from getCDNBundleSettings
* @returns {Array} Array of unique collection IDs
*/
function getAllCollectionIds(bundleSettings) {
const variantCollections = getVariantCollections(bundleSettings);
const allCollectionIds = variantCollections.flatMap(variant =>
variant.collections.map(collection => collection.collectionId)
);
return [...new Set(allCollectionIds)];
}
/**
* Get collections for a specific variant
* @param {Object} bundleSettings - The bundle settings from getCDNBundleSettings
* @param {string} externalVariantId - The Shopify variant ID
* @returns {Array} Array of collection objects for the variant
*/
function getCollectionsForVariant(bundleSettings, externalVariantId) {
const variantCollections = getVariantCollections(bundleSettings);
const variant = variantCollections.find(v => v.externalVariantId === externalVariantId);
return variant ? variant.collections : [];
}
```
### Usage Example
```javascript
// After loading bundle settings
const bundleSettings = await recharge.cdn.getCDNBundleSettings('8138138353916');
// Get all variant collections
const variantCollections = getVariantCollections(bundleSettings);
console.log('Variant Collections:', variantCollections);
// Get all unique collection IDs
const collectionIds = getAllCollectionIds(bundleSettings);
console.log('Collection IDs:', collectionIds);
// Get collections for specific variant
const collections = getCollectionsForVariant(bundleSettings, '44636069724412');
console.log('Collections for variant:', collections);
```
### Bundle Rules Explanation
**Important:** Understanding bundle sizing and collection constraints:
- **`variant.ranges`** - Defines the total bundle size for dynamic pricing:
- `quantity_min: null` = Optional (0 minimum, use 0 as fallback)
- `quantity_min: 1` = Must include at least 1 item in bundle
- `quantity_max: null` = Infinite maximum (no upper limit)
- `quantity_max: 20` = Maximum 20 items allowed in bundle
- **`items_count`** - Should be ignored in favor of ranges for dynamic bundles
- **`option_sources` quantity constraints** - Per-collection rules:
- `quantity_min: null` = Collection is optional (no minimum required)
- `quantity_min: 3` = Must include at least 3 items from this collection
- `quantity_max: 5` = Can include maximum 5 items from this collection
- `quantity_max: null` = No maximum limit for this collection
### Example Output
```javascript
// variantCollections result:
[
{
variantId: 32967,
externalVariantId: "44636069724412",
enabled: true,
itemsCount: 2, // IGNORE: Use ranges instead
collections: [
{
optionSourceId: 403608,
collectionId: "400595910908", // Breakfast collection
quantityMin: null, // Optional collection (no minimum)
quantityMax: null // No maximum limit
},
{
optionSourceId: 403609,
collectionId: "400321020156", // Lunch collection
quantityMin: 3, // Must include at least 3 lunch items
quantityMax: 5 // Can include max 5 lunch items
}
],
ranges: [{ id: 1298, quantity_min: 1, quantity_max: null }], // Bundle size: 1-β items total (no max limit)
defaults: [...]
}
]
```
## β‘ REQUIRED: How Bundle Selection Works
### Selection Rules Implementation
When building a bundle widget, you need to track selections and validate against both bundle-level and collection-level constraints:
#### 1. Bundle-Level Constraints (ranges)
```javascript
// Example: ranges = [{ quantity_min: 1, quantity_max: null }]
// Means: Bundle must contain 1-β items total
function validateBundleSize(selections, ranges) {
const totalItems = selections.reduce((sum, item) => sum + item.quantity, 0);
const range = ranges[0]; // Usually one range per variant
const minRequired = range.quantity_min || 0;
const maxAllowed = range.quantity_max || Infinity;
return totalItems >= minRequired && totalItems <= maxAllowed;
}
```
#### 2. Collection-Level Constraints (option_sources)
```javascript
// Example: { quantityMin: 3, quantityMax: 5, collectionId: "123" }
// Means: Must have 3-5 items from this specific collection
function validateCollectionConstraints(selections, collections) {
return collections.every(collection => {
const collectionItems = selections.filter(item =>
item.collectionId === collection.collectionId
);
const totalFromCollection = collectionItems.reduce((sum, item) => sum + item.quantity, 0);
const minRequired = collection.quantityMin || 0;
const maxAllowed = collection.quantityMax || Infinity;
return totalFromCollection >= minRequired && totalFromCollection <= maxAllowed;
});
}
```
#### 3. Selection Data Structure
```javascript
// Keep selections in this format:
const bundleSelections = {
variantId: "44636069724412",
items: [
{
productId: "gid://shopify/Product/123",
variantId: "gid://shopify/ProductVariant/456",
quantity: 2,
collectionId: "400595910908",
title: "Product Name",
price: "10.00"
}
],
isValid: true, // Check against all constraints
totalItems: 2,
errors: [] // Validation error messages
};
```
## π§ REFERENCE: Complete Bundle Widget Implementation
### HTML Structure
Build your bundle widget with these components:
```html
Bundle Widget
Bundle Builder
Loading bundle...
ποΈ Bundle Widget
π Your Bundle Selection
```
### Theme-Aligned Styling (Online Store β Use Theme Settings)
When building storefront widgets, align with the storeβs theme rather than hardcoding styles:
- Reference theme schema settings via Liquid `settings` (from `config/settings_schema.json` / `config/settings_data.json`).
- Prefer theme CSS variables and existing utility/component classes over bespoke CSS.
- Expose tokens and class hooks to JS via inline CSS variables or data attributes rendered by Liquid.
Example (Liquid + HTML) injecting theme values and class hooks:
```liquid
';
} else {
validationMessages.innerHTML = '';
}
// Update add to cart button
addToCartBtn.disabled = !bundleSelections.isValid;
addToCartBtn.style.background = bundleSelections.isValid ? '#28a745' : '#ccc';
}
// Main initialization
async function initializeBundleWidget() {
try {
// Initialize clients
initializeShopifyClient();
await recharge.init({
storeIdentifier: 'your-store.myshopify.com',
appName: 'Bundle Widget',
appVersion: '1.0.0'
});
// Load bundle settings
const bundleSettings = await recharge.cdn.getCDNBundleSettings('your-product-id');
const variantCollections = getVariantCollections(bundleSettings);
// Setup widget
populateBundleVariantPicker(variantCollections);
setupEventListeners(variantCollections);
// Auto-select first variant
if (variantCollections.length > 0) {
await displayBundleContent(variantCollections[0]);
}
document.getElementById('status').style.display = 'none';
document.getElementById('bundleWidget').style.display = 'block';
} catch (error) {
console.error('Error initializing bundle widget:', error);
}
}
// Start the widget
initializeBundleWidget();
```
### Key Features Implemented
1. **Real-time Validation**: Validates bundle constraints as users make selections
2. **Dynamic Pricing**: Shows total price updates in real-time
3. **Collection Rules**: Enforces per-collection min/max constraints
4. **Bundle Size Rules**: Enforces total bundle size constraints
5. **Visual Feedback**: Clear success/error states with helpful messages
6. **Responsive Design**: Works on desktop and mobile devices
7. **Accessibility**: Proper labels and keyboard navigation
8. **Error Handling**: Graceful error handling and user feedback
### Integration Tips
1. **Configuration**: Update store domain, product ID, and access token
2. **Styling**: Customize CSS to match your site's design
3. **Cart Integration**: Implement actual cart functionality in the add to cart handler
4. **Error Handling**: Add proper error boundaries and fallbacks
5. **Loading States**: Implement appropriate loading indicators
6. **Analytics**: Add tracking for bundle interactions and conversions
## π¨ OPTIONAL: Working with Shopify Metafields in Bundle Widgets
> **π¨ IMPORTANT:** Metafields are completely OPTIONAL. You can build a fully functional bundle widget without any metafields. This section shows how to enhance your widget IF you already have custom product data stored in Shopify metafields.
### When to Use This Section
**β Use metafields IF:**
- You already have custom product attributes in Shopify metafields
- You want to display additional product information beyond title/price/image
- Your products have special characteristics that help customers make choices (materials, ingredients, certifications, etc.)
**β Skip this section IF:**
- You don't have metafields set up in your store
- You want to build a basic bundle widget first
- You're happy with just showing product title, price, and image
### Example Use Cases (Choose What Applies to Your Store)
**These are just examples - use what matches your store type:**
**IF you're a meal prep company:** Display dietary preferences (Keto, Vegan) and allergen warnings
**IF you're a clothing store:** Show materials (Cotton, Polyester), care instructions, or style tags
**IF you're a supplement company:** Display ingredients, certifications, or health benefits
**IF you're a beauty brand:** Show skin type compatibility, ingredient highlights, or product benefits
**IF you sell any products with custom attributes:** This section shows how to display them in your bundle widget
> **π‘ Remember:** These are just examples! The meal prep scenario below is ONE possible use case. Replace the metafield names, types, and styling with whatever matches your actual store's data.
### Setting Up Metafields
#### 1. Example Metafield Structure (Meal Prep Use Case)
> **π Note:** This is just ONE example for a meal prep company. Replace these metafield types with whatever matches your actual store's needs.
If you were a meal prep company, you might create these metafields in your Shopify Admin:
**Dietary Preferences** (`shopify.dietary-preferences`)
- Type: `list.metaobject_reference`
- Metaobject Definition: `dietary_preference`
- Fields: `label` (text), `description` (text)
**Allergen Information** (`shopify.allergen-information`)
- Type: `list.metaobject_reference`
- Metaobject Definition: `allergen_info`
- Fields: `label` (text), `severity` (text)
**Product Badges** (`custom.badge`)
- Type: `list.metaobject_reference`
- Metaobject Definition: `product_badge`
- Fields: `label` (text), `background` (color), `text_color` (color)
#### 2. Example GraphQL Query with Metafields
> **π Note:** This example query includes the meal prep metafields shown above. Modify the metafield queries to match YOUR store's actual metafield structure.
Here's how you would update your Shopify Storefront API query to include metafields (using the meal prep example). Since collections can contain more than 250 products, you need to implement pagination to load all products:
```graphql
query getCollectionWithMetafields($id: ID!, $first: Int!, $after: String) {
collection(id: $id) {
id
title
description
products(first: $first, after: $after) {
edges {
node {
id
title
description
# Dietary preferences metafield
dietaryPreferences: metafield(namespace: "shopify", key: "dietary-preferences") {
id
type
references(first: 10) {
nodes {
... on Metaobject {
id
fields {
key
value
}
}
}
}
}
# Allergen information metafield
allergenInformation: metafield(namespace: "shopify", key: "allergen-information") {
id
type
references(first: 10) {
nodes {
... on Metaobject {
id
fields {
key
value
}
}
}
}
}
# Product badges metafield
badges: metafield(namespace: "custom", key: "badge") {
id
type
references(first: 10) {
nodes {
... on Metaobject {
id
fields {
key
value
}
}
}
}
}
# Standard product fields
images(first: 5) {
edges {
node {
url
altText
}
}
}
variants(first: 100) {
edges {
node {
id
price {
amount
currencyCode
}
availableForSale
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
```
### Loading All Products with Pagination
Since Shopify limits each GraphQL request to a maximum of 250 resources, you need to implement pagination to load all products from large collections. Here's how to handle pagination:
```javascript
/**
* Fetch all products from a collection using pagination
* @param {string} collectionId - Shopify collection ID
* @returns {Promise