# 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 (do not proceed until all are answered):**
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: NEVER ASSUME ANY ANSWER TO Q1–Q4. THE USER MUST ANSWER EACH EXPLICITLY.**
If any answer is missing or ambiguous, ask a direct, targeted question to get it before continuing.
## Resolve the Path
Use this mapping once Q1–Q4 are complete:
- **Online Store + Fixed Price** → follow "Online Store + Fixed Price" steps
- **Online Store + Dynamic** → follow "Online Store + Dynamic" steps
- **Headless Store + Fixed Price** → follow "Headless Store + Fixed Price" steps
- **Headless Store + Dynamic** → follow "Headless Store + Dynamic" steps
Make a plan first and tell back to the user what you are going to do based on the data gathered for that specific path:
Online Store + Fixed Price:
- REQUIRED: Setup workflow -> Online Store
- REQUIRED: Loading Data: Recharge Bundle Settings and Product Information
- REQUIRED: Extracting Collections from Bundle Settings
- REQUIRED: How Bundle Selection Works
- REQUIRED: Bundle Rules Explanation
- REQUIRED: Fixed Price Bundle Implementation
Online Store + Dynamic:
- REQUIRED: Setup workflow -> Online Store
- REQUIRED: Loading Data: Recharge Bundle Settings and Product Information
- REQUIRED: Extracting Collections from Bundle Settings
- REQUIRED: How Bundle Selection Works
- REQUIRED: Bundle Rules Explanation
- REQUIRED: Adding Recharge Dynamic Bundles to Cart **(Make sure you follow this section)**
- REQUIRED: Dynamic Bundle Price Handling - Special Implementation Requirements **(Make sure you follow this section)**
- REQUIRED: Selling Plan Matching Patterns **(Make sure you follow this section - not doing always cause bugs when adding items as subscriptions to the cart)**
Headless Store + Fixed Price:
- REQUIRED: Setup workflow -> Headless/Custom Only
- REQUIRED: Loading Data: Recharge Bundle Settings and Product Information
- REQUIRED: Extracting Collections from Bundle Settings
- REQUIRED: How Bundle Selection Works
- REQUIRED: Bundle Rules Explanation
- REQUIRED: Fixed Price Bundle Implementation
Headless Store + Dynamic:
- REQUIRED: Setup workflow -> Headless/Custom Only
- REQUIRED: Loading Data: Recharge Bundle Settings and Product Information
- Using GraphQL with recharge.cdn.getCDNBundleSettings()
- REQUIRED: Extracting Collections from Bundle Settings
- REQUIRED: How Bundle Selection Works
- REQUIRED: Bundle Rules Explanation
- REQUIRED: Adding Recharge Dynamic Bundles to Cart **(Make sure you follow this section)**
- REQUIRED: Dynamic Bundle Price Handling - Special Implementation Requirements **(Make sure you follow this section)**
- REQUIRED: Selling Plan Matching Patterns **(Make sure you follow this section \- not doing always cause bugs when adding items as subscriptions to the cart)**
## REQUIRED: Setup workflow
### 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: Load Recharge SDK , 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.
#### 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 theme Block
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
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 %}
```
Add these script tags before any code that calls `recharge.*` (this should go in the block).
```html
```
#### 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.
Use the following section template exactly as it is, don’t add anything else to the schema, the only change would be the right name based on the section name:
```
{% content_for 'blocks' %}
{% schema %}
{
"name": "Bundle Builder",
"settings": [
],
"blocks": [
{
"type": "@theme" // do not add any other property here, type is the only valid property and it takes @theme or @app
},
{
"type": "@app" // do not add any other property here, type is the only valid property
}
]
}
{% endschema %}
```
#### 4. 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"]
```
#### 5. 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
### Headless/Custom Only
When building a bundle widget for headless or custom implementations, follow this setup process:
#### 1. Gather Required Configuration
Only If implementing a widget for a headless or custom store then gather these other details:
**Required Information:**
- **Store Domain**: The full Shopify store domain
- Example: `your-store.myshopify.com`
- Format: `https://your-store.myshopify.com` (include https://)
- This is not required if building for the online store since the store domain is automatically available in the Shopify theme context via `{{shop.permanent_domain}}`
- **Storefront Access Token**: Shopify Storefront Public access token
- 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`
- This is not required if building for the online store since it will come from `product.id` or `closest.product.id` when the widget is used on a product page
#### 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)
// Shopify Storefront Public access token
storefrontAccessToken: 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 || 'your-token-here',
bundleProductId: process.env.BUNDLE_PRODUCT_ID || '8138138353916',
appName: 'Bundle Widget',
appVersion: '1.0.0'
};
```
## REQUIRED: Loading Data: Recharge Bundle Settings and Product Information
To load bundle configuration from Recharge, use the CDN version of their SDK:
```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.
There are two methods available for obtaining bundle settings, depending on your implementation type:
## CRITICAL: Choose the Correct API Method
### Method Selection Decision Tree
```
Where is your code running?
├─ Inside a Shopify Theme (Liquid files)?
│ └─ ✅ Use recharge.bundleData.loadBundleData()
│
└─ Anywhere else?
├─ React app
├─ Vue app
├─ Next.js
├─ Custom HTML/JS
├─ Mobile app
└─ Any headless/custom storefront
└─ ✅ Use recharge.cdn.getCDNBundleSettings() + Shopify GraphQL
```
**For Shopify Online Store (Theme-based) implementations ONLY:**
- Use `recharge.bundleData.loadBundleData()`
- Returns complete bundle data including:
- Bundle settings
- Full collection and product information
- Everything needed to build your widget
- **DOES NOT WORK in headless/custom environments**
- **Only works when code runs inside Shopify theme context**
**For Headless/Custom implementations (React, Vue, Next.js, custom storefronts):**
- Use `recharge.cdn.getCDNBundleSettings()`
- Returns only bundle settings
- You'll need to fetch collections and product information separately using Shopify's GraphQL API
- **DO NOT use loadBundleData() - it will fail in headless environments**
### Using recharge.bundleData.loadBundleData() (THEME-BASED ONLY)
**WARNING: This method ONLY works in Shopify theme environments (Liquid templates).**
**If you're building:**
- Headless storefront → Skip this section, use getCDNBundleSettings() below
- React/Vue/Next.js app → Skip this section, use getCDNBundleSettings() below
- Custom HTML/JS storefront → Skip this section, use getCDNBundleSettings() below
- Shopify theme (Liquid) → Use this method
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:
```ts
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:**
```ts
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:**
```ts
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:**
```ts
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:**
```ts
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 };
}
```
**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
};
}
```
**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 [];
}
```
**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.
### Using GraphQL with recharge.cdn.getCDNBundleSettings() (HEADLESS/CUSTOM ONLY)
**USE THIS METHOD for headless/custom storefronts** (React, Vue, Next.js, custom HTML/JS)
**DO NOT use loadBundleData() in headless environments - it will not work**
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}` }
});
```
#### 2. 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.
`;
}
```
#### 3. 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": []
}
]
}
```
#### 4. GraphQL Collection Query
Use this GraphQL query to fetch collection products:
```
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
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
```
#### 5. 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 }
};
}
```
#### 6. 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
}));
}
```
#### 7. 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;
}
```
## 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);
```
##
**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
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:
```
';
} 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
## REQUIRED: Fixed Price Bundle Implementation
**IMPORTANT: This section is for FIXED PRICE BUNDLES only.**
Fixed price bundles have a simpler implementation than dynamic bundles. The key difference is that customers pay the bundle product price regardless of their selections, and you add only the bundle product to the cart with a unique `_rb_id`.
### Understanding Fixed Price Bundles
**Key Characteristics:**
- Bundle product has a fixed price (e.g., $29.99)
- Individual product selections don't affect the total price
- Products in collections are available for selection but not charged at their normal price
- Fixed sizes determined by variant settings
- Single bundle product added to cart with `_rb_id` property
**Important:** The `_rb_id` property is crucial for fixed price bundles. Without it, the bundle won't work correctly in checkout.
### Fixed Price Bundle Cart Integration
Unlike dynamic bundles that use `recharge.bundle.getDynamicBundleItems()`, fixed price bundles use `recharge.bundle.getBundleId()` to generate a unique ID for the selections.
#### Selling Plans for Fixed Price Bundles
- Use the selling plan from the bundle product level; apply it to the single bundle line item.
- Child selections do not need individual selling plan matching.
- This differs from dynamic bundles, where each child product requires its own selling plan ID matched by frequency and discount.
#### 1. Create Bundle Selection Object
```javascript
// Bundle product data
const bundleProductData = {
variantId: '41291293425861', // Bundle product variant ID
productId: '7134322196677', // Bundle product ID
sellingPlan: 743178437, // Optional: subscription selling plan
};
// Bundle selections object
const bundle = {
externalVariantId: bundleProductData.variantId,
externalProductId: bundleProductData.productId,
selections: [
{
collectionId: '285790863557', // Collection ID
externalProductId: '7200062308549', // Selected product ID
externalVariantId: '41504991412421', // Selected variant ID
quantity: 2, // Quantity selected
},
{
collectionId: '288157827269',
externalProductId: '7200061391045',
externalVariantId: '41510929465541',
quantity: 2,
},
],
};
```
#### 2. Generate Bundle ID and Add to Cart
```javascript
async function addFixedPriceBundleToCart(bundle, bundleProductData) {
try {
// Validate bundle selections against bundle product rules
console.log('🔍 Validating bundle selections against product rules...');
const { valid, error } = await recharge.bundle.validateBundleSelection(bundle); // validateBundleSelection only works for theme-based widget/online store. For headless we need a customer validation see #### 7. Validation Implementation
if (!valid) {
console.error('❌ Bundle selection validation failed:', error);
throw new Error(`Bundle selection validation failed: ${error}`);
}
console.log('✅ Bundle selections validated successfully against product rules');
// Generate unique bundle ID for selections
const rbId = await recharge.bundle.getBundleId(bundle);
console.log('✅ Generated bundle ID:', rbId);
// Prepare cart data
const cartData = {
items: [
{
id: bundleProductData.variantId,
quantity: 1,
selling_plan: bundleProductData.sellingPlan, // Optional
properties: {
_rb_id: rbId // Critical: This links the selections to the bundle
},
},
],
};
// Add to cart using Shopify Ajax Cart API
const response = await fetch(window.Shopify.routes.root + 'cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cartData),
});
if (response.ok) {
console.log('✅ Fixed price bundle added to cart successfully');
// Redirect to cart or show success message
window.location.href = '/cart';
} else {
throw new Error('Failed to add bundle to cart');
}
} catch (error) {
console.error('❌ Error adding fixed price bundle to cart:', error);
// Handle error (show user message, etc.)
}
}
```
### Key Differences from Dynamic Bundles
**Fixed Price Bundles:**
- Use `recharge.bundle.getBundleId()` instead of `getDynamicBundleItems()`
- Add only the bundle product to cart with `_rb_id` property
- No individual product pricing calculations
- Simpler implementation overall
**Dynamic Bundles:**
- Use `recharge.bundle.getDynamicBundleItems()`
- Add individual products to cart
- Complex price calculations and selling plan matching
- More sophisticated implementation
### Important Notes
1. **Critical:** Always use `_rb_id` property when adding fixed price bundles to cart
2. **Bundle ID:** The `_rb_id` must be generated using `recharge.bundle.getBundleId()`
3. **Single Product:** Only add the bundle product to cart, not individual selections
4. **Fixed Pricing:** Bundle price comes from bundle product, not individual selections
5. **Size Constraints:** Bundle size is determined by variant settings
## REQUIRED: Adding Recharge Dynamic Bundles to Cart
**IMPORTANT: This section is for DYNAMIC BUNDLES only.**
For **Fixed Price Bundles**, see: [Fixed Price Bundle Implementation](#fixed-price-bundle-implementation)
When customers complete their dynamic bundle selection, you need to add the bundle to the cart with the proper Recharge dynamic bundle structure. The implementation differs depending on whether you're using Shopify's Storefront GraphQL API or the traditional online store setup.
### Bundle Cart Implementation
When adding Recharge dynamic bundles to cart, **always use the Recharge SDK's `getDynamicBundleItems()` function**. This ensures proper bundle structure, linking, and compatibility with Recharge's system.
**CRITICAL: When customer selects a subscription option, you MUST include the selling plan ID in the cart mutation for each line item. Failing to do this will result in items being added as one-time purchases even when subscription is selected.**
### Using Recharge SDK's getDynamicBundleItems()
**IMPORTANT:** Always use the Recharge SDK's `getDynamicBundleItems()` function instead of manually constructing cart payloads. This ensures proper bundle structure and compatibility.
#### 1. Convert Bundle Selections to Recharge Format
```javascript
/**
* Convert bundle selections to Recharge bundle format
*/
function convertToRechargeBundleFormat(bundleSelections) {
// Bundle product data
const bundleProductData = {
productId: '8138138353916', // Bundle product ID
variantId: bundleSelections.variant.externalVariantId.replace('gid://shopify/ProductVariant/', ''),
handle: 'bundle-product', // Bundle product handle
// sellingPlan: 2730066117, // Optional, exclude for now
};
// Bundle selections in Recharge format
const bundle = {
externalVariantId: bundleSelections.variant.externalVariantId.replace('gid://shopify/ProductVariant/', ''),
externalProductId: '8138138353916', // Bundle product ID
selections: bundleSelections.items.map(item => ({
collectionId: item.collectionId,
externalProductId: item.productId.replace('gid://shopify/Product/', ''),
externalVariantId: item.variantId.replace('gid://shopify/ProductVariant/', ''),
quantity: item.quantity,
// sellingPlan: 2730098885, // Optional, exclude for now
}))
};
return { bundle, bundleProductData };
}
```
#### 2. Get Cart Items from Recharge SDK
```javascript
/**
* Get properly formatted cart items using Recharge SDK
*/
async function getRechargeCartItems(bundleSelections) {
const { bundle, bundleProductData } = convertToRechargeBundleFormat(bundleSelections);
// Use Recharge SDK to get cart items
const cartItems = await recharge.bundle.getDynamicBundleItems(bundle, bundleProductData.handle);
return cartItems;
}
```
### Implementation for Storefront GraphQL API
For headless implementations or custom storefronts using Shopify's Storefront GraphQL API:
#### 1. GraphQL Mutation for Cart Creation
```
mutation cartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
estimatedCost {
totalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
estimatedCost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
product {
title
}
}
}
attributes {
key
value
}
}
}
}
}
userErrors {
field
message
}
}
}
```
#### 2. Convert Recharge Items to GraphQL Format
```javascript
/**
* Convert Recharge cart items to GraphQL format
* Note: IDs from Recharge SDK are already strings, ready for GraphQL
* IMPORTANT: Pass selectedSellingPlan when subscription is selected
*/
function convertRechargeItemsToGraphQL(rechargeCartItems, selectedSellingPlan = null) {
return rechargeCartItems.map(item => {
const line = {
merchandiseId: `gid://shopify/ProductVariant/${item.id}`,
quantity: item.quantity,
attributes: Object.entries(item.properties || {}).map(([key, value]) => ({
key,
value: String(value)
}))
};
// CRITICAL: Add selling plan ID when subscription selected
if (selectedSellingPlan) {
line.sellingPlanId = selectedSellingPlan.id;
}
return line;
});
}
```
#### 3. JavaScript Implementation for GraphQL
```javascript
/**
* Add bundle to cart using Storefront GraphQL API
*/
async function addBundleToCartGraphQL(bundleSelections) {
try {
console.log('🛒 Adding bundle to cart via GraphQL...', bundleSelections);
// Convert to Recharge bundle format
const { bundle, bundleProductData } = convertToRechargeBundleFormat(bundleSelections);
// Get cart items from Recharge SDK
const rechargeCartItems = await recharge.bundle.getDynamicBundleItems(bundle, bundleProductData.handle);
console.log('🔧 Recharge cart items:', rechargeCartItems);
// Convert to GraphQL format
// CRITICAL: Pass selectedSellingPlan for subscriptions
const cartLines = convertRechargeItemsToGraphQL(rechargeCartItems, selectedSellingPlan);
const cartCreateMutation = `
mutation cartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
estimatedCost {
totalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
product {
title
}
}
}
attributes {
key
value
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const response = await shopifyClient.request(cartCreateMutation, {
variables: {
input: {
lines: cartLines
}
}
});
if (response.data?.cartCreate?.userErrors?.length > 0) {
throw new Error('Cart creation failed: ' + response.data.cartCreate.userErrors.map(e => e.message).join(', '));
}
const cart = response.data?.cartCreate?.cart;
if (!cart) {
throw new Error('Failed to create cart');
}
console.log('✅ Bundle added to cart successfully:', {
cartId: cart.id,
checkoutUrl: cart.checkoutUrl,
totalCost: cart.estimatedCost?.totalAmount?.amount,
itemCount: cart.lines.edges.length
});
// Redirect to checkout
window.location.href = cart.checkoutUrl;
return cart;
} catch (error) {
console.error('❌ Error adding bundle to cart:', error);
alert('Failed to add bundle to cart. Please try again.');
throw error;
}
}
```
### Implementation for Shopify Online Store
For traditional Shopify theme implementations using the Ajax Cart API:
#### 1. Add Bundle to Cart using Recharge SDK
```javascript
/**
* Add bundle to cart using Shopify Ajax Cart API with Recharge SDK
*/
async function addBundleToCartAjax(bundleSelections) {
try {
console.log('🛒 Adding bundle to cart via Ajax...', bundleSelections);
// Convert to Recharge bundle format
const { bundle, bundleProductData } = convertToRechargeBundleFormat(bundleSelections);
// Get cart items from Recharge SDK
const rechargeCartItems = await recharge.bundle.getDynamicBundleItems(bundle, bundleProductData.handle);
console.log('🔧 Recharge cart items:', rechargeCartItems);
// CRITICAL: Add selling plan to each item if subscription selected
if (bundleSelections.selectedSellingPlan) {
rechargeCartItems.forEach(item => {
item.selling_plan = bundleSelections.selectedSellingPlan.id.replace('gid://shopify/SellingPlan/', '');
});
}
const cartData = { items: rechargeCartItems };
const response = await fetch(window.Shopify.routes.root + 'cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cartData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || errorData.description || 'Failed to add to cart');
}
const result = await response.json();
console.log('✅ Bundle added to cart successfully:', result);
// Redirect to cart page
window.location.href = '/cart';
return result;
} catch (error) {
console.error('❌ Error adding bundle to cart:', error);
alert('Failed to add bundle to cart. Please try again.');
throw error;
}
}
```
### Important Notes
1. **Use Recharge SDK**: Always use `recharge.bundle.getDynamicBundleItems()` instead of manual construction
2. **Variant ID Format**: GraphQL uses full GIDs while Ajax Cart uses numeric IDs
3. **Bundle Product Handle**: Ensure you have the correct bundle product handle for `getDynamicBundleItems()`
4. **Selling Plans for Subscriptions**: When customer selects a subscription, you must include the selling plan ID in the cart request. For GraphQL use `sellingPlanId` field, for Ajax Cart API use `selling_plan` field. Without this, items will be added as one-time purchases.
### Recharge SDK Output Structure
The `recharge.bundle.getDynamicBundleItems()` function returns cart items with this structure:
```javascript
[
{
"id": "43650317451516", // String variant ID (not numeric)
"quantity": 5,
"properties": {
"_rc_bundle": "npC1d-NSN:8138138353916", // Bundle identifier
"_rc_bundle_variant": "44636069724412", // Bundle variant ID
"_rc_bundle_parent": "bundle-product", // Bundle product handle
"_rc_bundle_collection_id": "400595910908" // Collection ID for this item
}
},
{
"id": "43650318696700", // Another bundle item
"quantity": 2,
"properties": {
"_rc_bundle": "npC1d-NSN:8138138353916", // Same bundle identifier
"_rc_bundle_variant": "44636069724412", // Same bundle variant
"_rc_bundle_parent": "bundle-product", // Same bundle handle
"_rc_bundle_collection_id": "400595910908" // Collection for this item
}
}
]
```
### Key Properties Explained:
- **`id`**: Shopify variant ID as string (ready for cart APIs)
- **`quantity`**: Number of this variant to add to cart
- **`_rc_bundle`**: Unique bundle identifier (format: `{hash}:{bundle_product_id}`)
- **`_rc_bundle_variant`**: The bundle product's variant ID
- **`_rc_bundle_parent`**: Bundle product handle
- **`_rc_bundle_collection_id`**: Source collection ID for this item
### Important Notes:
1. **Variant IDs are strings**, not numbers (works directly with both GraphQL and Ajax Cart)
2. **Recharge uses `_rc_bundle` prefix** for all bundle-related properties
3. **All items share the same `_rc_bundle` identifier** to link them together
4. **No manual bundle ID generation needed** \- Recharge SDK handles everything
5. **Only bundle items are returned** \- The SDK returns the actual products to add to cart, not the bundle product itself
6. **Ready for direct use** \- These items can be passed directly to cart APIs without modification
## REQUIRED: Dynamic Bundle Price Handling \- Special Implementation Requirements
**IMPORTANT: This section is for DYNAMIC BUNDLES only.**
For **Fixed Price Bundles**, pricing is handled automatically \- see: [Fixed Price Bundle Implementation](#fixed-price-bundle-implementation)
### Why Dynamic Bundles Need Special Price Handling
Dynamic bundles require sophisticated price handling because they combine products from different collections, each with their own pricing and selling plans. Unlike fixed-price bundles where pricing is static, dynamic bundles must:
1. **Handle Real-time Price Calculations**: Prices change based on user selections and subscription choices
2. **Normalize Currency Format**: Shopify API returns prices in cents (e.g., 800 for $8.00) requiring conversion to display format
3. **Support Compare-at Pricing**: Show original prices with strikethrough when subscription discounts apply
4. **Update Multiple Product Cards**: Each product card must recalculate pricing when subscription mode changes
### Critical Implementation Requirements
**For Dynamic Bundles, you must implement**:
1. **Price normalization** from cents to dollars for proper display
2. **Real-time price updates** when users toggle between one-time and subscription
3. **Selling plan allocation lookups** to find discounted subscription prices
4. **Compare-at pricing logic** to show savings clearly
### Complete Solution Implementation
#### 1. Price Normalization and Currency Formatting
Add these utility functions at the beginning of your JavaScript:
```javascript
// Price utility functions (following reference widget patterns)
function normalizePriceFromCents(priceInCents) {
// Convert cents to dollars (like reference widget: price / 100)
return priceInCents / 100;
}
function formatCurrency(amount, currencyCode = 'USD') {
// Format currency similar to Vue.js $n(price, 'currency')
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
function createPriceDisplay(pricing, showFrequency = true, selectedSellingPlan = null) {
const root = document.getElementById('bundleWidget');
const priceCompareClass = root?.dataset?.priceCompareClass || 'price-compare-at';
let priceHTML = '';
// Show compare at price if different and higher (strikethrough)
if (pricing.compareAt > pricing.active) {
priceHTML += `${formatCurrency(pricing.compareAt)} `;
}
priceHTML += formatCurrency(pricing.active);
// Add subscription frequency indicator
if (pricing.isSubscription && showFrequency && selectedSellingPlan) {
const frequency = selectedSellingPlan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value || 'subscription';
priceHTML += ` (${frequency})`;
}
return priceHTML;
}
```
#### 2. Enhanced Product Pricing Calculation
Update your pricing calculation to handle cents properly:
```javascript
// Calculate product pricing for one-time vs subscription (normalizes from cents to dollars)
function calculateProductPricing(product, variant) {
// Normalize prices from cents to dollars
const oneTimePrice = normalizePriceFromCents(variant.price);
let subscriptionPrice = oneTimePrice;
let compareAtPrice = variant.compare_at_price ? normalizePriceFromCents(variant.compare_at_price) : 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);
// For compare-at, show the higher of original price or compare_at_price
if (sellingPlanAllocation.compare_at_price) {
compareAtPrice = Math.max(
normalizePriceFromCents(sellingPlanAllocation.compare_at_price),
oneTimePrice
);
} else {
// If no specific compare_at, use one-time price as compare-at for discounted subscriptions
compareAtPrice = oneTimePrice;
}
}
}
return {
oneTime: oneTimePrice,
subscription: subscriptionPrice,
compareAt: compareAtPrice,
active: bundleSelections.subscribeAndSave ? subscriptionPrice : oneTimePrice,
isSubscription: bundleSelections.subscribeAndSave
};
}
```
#### 3. Real-time Price Updates
Implement functions to update pricing when subscription mode changes:
```javascript
// Format price display with comparison pricing (fixes scope issue)
function formatPriceDisplay(pricing) {
return createPriceDisplay(pricing, true, bundleSelections.selectedSellingPlan);
}
// Update all product cards with current pricing
function updateAllProductPricing() {
console.log(`🔄 updateAllProductPricing() called - subscription mode: ${bundleSelections.subscribeAndSave}`);
const productCards = document.querySelectorAll('.bundle-product-card');
console.log(`📦 Found ${productCards.length} product cards to update`);
productCards.forEach((card, index) => {
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);
const priceDisplay = formatPriceDisplay(pricing);
console.log(`💰 Card ${index + 1} pricing:`, {
productId,
productTitle: product.title,
oneTime: pricing.oneTime,
subscription: pricing.subscription,
compareAt: pricing.compareAt,
active: pricing.active,
isSubscription: pricing.isSubscription,
priceDisplay
});
const priceContainer = card.querySelector('[data-price-container]');
if (priceContainer) {
priceContainer.innerHTML = priceDisplay;
console.log(`✅ Updated price display for card ${index + 1}`);
} else {
console.warn(`⚠️ No price container found for card ${index + 1}`);
}
}
});
}
```
#### 4. Subscribe & Save Toggle Integration
Update your subscription toggle to trigger price updates:
```javascript
// Subscribe checkbox event listener
subscribeCheckbox.addEventListener('change', function() {
console.log(`🔄 Subscribe checkbox changed to: ${this.checked}`);
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;
console.log(`✅ Auto-selected selling plan:`, bundleSelections.selectedSellingPlan);
}
} else {
sellingPlanSelector.style.display = 'none';
bundleSelections.selectedSellingPlan = null;
sellingPlanSelect.value = '';
}
// Update pricing and summary (CRITICAL FOR REAL-TIME UPDATES)
updateAllProductPricing();
updateBundleSummary();
});
// Selling plan select event listener
sellingPlanSelect.addEventListener('change', function() {
const selectedPlanId = this.value;
console.log(`📋 Selling plan changed to: ${selectedPlanId}`);
if (selectedPlanId) {
bundleSelections.selectedSellingPlan = availableSellingPlans.find(plan => plan.id === selectedPlanId);
console.log(`✅ Selected selling plan:`, bundleSelections.selectedSellingPlan);
} else {
bundleSelections.selectedSellingPlan = null;
}
// Update pricing and summary (CRITICAL FOR REAL-TIME UPDATES)
updateAllProductPricing();
updateBundleSummary();
});
```
#### 5. Bundle Summary Price Updates
Update bundle summary to use proper currency formatting:
```javascript
// In updateBundleSummary function
summaryContent.innerHTML = `
`;
container.appendChild(section);
}
```
## 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:
```
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
}
}
}
}
```
---
## REFERENCE: Production Checklist & Critical Requirements
Use this checklist to ensure your bundle widget meets all requirements for production deployment:
### Technical Implementation
**Common Requirements (Both Approaches):**
- [ ] Include Recharge SDK
- [ ] Initialize Recharge SDK with proper configuration
- [ ] Handle SDK loading errors gracefully
- [ ] Extract constraints from settings (don't hardcode rules)
- [ ] Handle missing or invalid bundle configurations
**For Online Store (Theme-based) Implementation:**
- [ ] Use `recharge.bundleData.loadBundleData()` for data loading
- [ ] Implement product context with `closest.product`
- [ ] Handle bundle data structure correctly
- [ ] Display loading states during data fetching
- [ ] Align with the store's theme. Pull values from Liquid settings (`config/settings_schema.json` / `config/settings_data.json`), use the theme's CSS variables and existing utility/component classes
**For Headless/Custom Implementation:**
- [ ] Include Shopify Storefront API Client: `https://unpkg.com/@shopify/storefront-api-client@1.0.9/dist/umd/storefront-api-client.min.js`
- [ ] Initialize Storefront API client with access token
- [ ] Extract bundle settings from Recharge: `recharge.cdn.getCDNBundleSettings()`
- [ ] Parse variant collections using `getVariantCollections()`
- [ ] Implement pagination for large collections (`fetchAllProductsFromCollection()`)
**Cart Implementation by Approach:**
**For Online Store (Theme-based):**
- [ ] Use Ajax Cart API: `fetch('/cart/add.js', { method: 'POST', body: JSON.stringify({ items: rechargeCartItems }) })`
- [ ] Auto-detect Shopify routes: `window.Shopify?.routes`
- [ ] Redirect to cart page after successful addition
**Enhanced Selling Plan Matching (CRITICAL):**
- [ ] **Use enhanced two-step selling plan matching approach**
- [ ] **Priority 1**: Check for direct `selling_plan_allocation` matches first
- [ ] **Priority 2**: Fall back to heuristic matching (frequency + discount)
- [ ] **Filter products**: Only show products that have compatible selling plans
**For Headless/Custom:**
- [ ] Implement GraphQL `cartCreate` mutation for headless
- [ ] Use Storefront API for cart management
- [ ] Handle cart tokens and session management
- [ ] Implement custom checkout flow