# 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 = `

${collectionData.title}

Choose ${binding.min || 0} - ${binding.max || '∞'} items

${collectionData.products.map(product => createProductCard(product, binding.id) ).join('')}
`; container.appendChild(section); } }); } ``` **Create Product Cards:** ```javascript function createProductCard(product, collectionId) { const variant = product.variants[0]; // Use first variant return `
${product.featured_image ? `${product.title}` : ''}

${product.title}

$${normalizePriceFromCents(variant.price).toFixed(2)}
0
`; } ``` **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...
``` ### 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: ```
``` Example (JS) reading theme-driven tokens/classes without hardcoding styles: ```javascript const root = document.getElementById('bundleWidget'); const accent = getComputedStyle(root).getPropertyValue('--bundle-accent').trim(); const buttonClass = root.dataset.buttonClass || 'button'; const priceCompareClass = root.dataset.priceCompareClass || 'price-compare-at'; document.getElementById('addToCartBtn')?.classList.add(buttonClass); // Use priceCompareClass when rendering compare-at spans ``` ### JavaScript Implementation Complete JavaScript implementation with all features: ```javascript // Bundle selection state management let bundleSelections = { variantId: null, variant: null, items: [], totalItems: 0, isValid: false, errors: [] }; // Shopify client let shopifyClient = null; // Initialize Shopify client function initializeShopifyClient() { shopifyClient = ShopifyStorefrontAPIClient.createStorefrontApiClient({ storeDomain: 'https://your-store.myshopify.com', apiVersion: '2025-01', publicAccessToken: 'your-storefront-access-token' }); } // Validation functions function validateBundleSize(selections, ranges) { if (!ranges || ranges.length === 0) return true; const totalItems = selections.reduce((sum, item) => sum + item.quantity, 0); const range = ranges[0]; const minRequired = range.quantity_min || 0; const maxAllowed = range.quantity_max || Infinity; return totalItems >= minRequired && totalItems <= maxAllowed; } function getValidationErrors(selections, variant) { const errors = []; if (!variant) return 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. Currently: ${totalItems}`); } if (totalItems > maxAllowed) { errors.push(`Bundle cannot exceed ${maxAllowed} items total. Currently: ${totalItems}`); } } // 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. Currently: ${totalFromCollection}`); } if (maxAllowed < Infinity && totalFromCollection > maxAllowed) { errors.push(`Collection ${index + 1} cannot exceed ${maxAllowed} items. Currently: ${totalFromCollection}`); } }); return errors; } // Update item quantity function updateItemQuantity(productId, variantId, collectionId, change, productData) { const existingItemIndex = bundleSelections.items.findIndex(item => item.productId === productId && item.variantId === variantId ); if (existingItemIndex >= 0) { bundleSelections.items[existingItemIndex].quantity += change; if (bundleSelections.items[existingItemIndex].quantity <= 0) { bundleSelections.items.splice(existingItemIndex, 1); } } else if (change > 0) { bundleSelections.items.push({ productId: productId, variantId: variantId, quantity: change, collectionId: collectionId, title: productData.title, price: productData.priceRange.minVariantPrice.amount, currencyCode: productData.priceRange.minVariantPrice.currencyCode }); } // Recalculate totals and validation bundleSelections.totalItems = bundleSelections.items.reduce((sum, item) => sum + item.quantity, 0); bundleSelections.errors = getValidationErrors(bundleSelections.items, bundleSelections.variant); bundleSelections.isValid = bundleSelections.errors.length === 0 && bundleSelections.totalItems > 0; updateProductQuantityDisplay(productId, variantId); updateBundleSummary(); } // Update UI displays function updateProductQuantityDisplay(productId, variantId) { const currentQty = getCurrentQuantity(productId, variantId); const productElements = document.querySelectorAll(`[data-product-id="${productId}"][data-variant-id="${variantId}"]`); productElements.forEach(element => { const quantityDisplay = element.querySelector('.quantity-display'); if (quantityDisplay) { quantityDisplay.textContent = currentQty; } const minusButton = element.querySelector('.quantity-btn-minus'); if (minusButton) { minusButton.disabled = currentQty <= 0; } }); } function updateBundleSummary() { const summaryContent = document.getElementById('summaryContent'); const validationMessages = document.getElementById('validationMessages'); const addToCartBtn = document.getElementById('addToCartBtn'); const bundleSummary = document.getElementById('bundleSummary'); if (bundleSelections.items.length === 0) { bundleSummary.style.display = 'none'; return; } bundleSummary.style.display = 'block'; // Display selected items const totalPrice = bundleSelections.items.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0 ); summaryContent.innerHTML = `
Selected Items:
${bundleSelections.items.map(item => `
${item.title} × ${item.quantity} = $${(parseFloat(item.price) * item.quantity).toFixed(2)}
`).join('')}
Total Items: ${bundleSelections.totalItems} | Total Price: $${totalPrice.toFixed(2)} ${bundleSelections.items[0]?.currencyCode || 'USD'}
`; // Display validation messages if (bundleSelections.errors.length > 0) { validationMessages.innerHTML = bundleSelections.errors.map(error => `
${error}
` ).join(''); } else if (bundleSelections.totalItems > 0) { validationMessages.innerHTML = '
✅ Bundle is valid and ready to add to cart!
'; } 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 = `
Selected Items:
${itemsWithPricing.map(item => `
${item.title} × ${item.quantity} ${formatCurrency(item.total)}
`).join('')}
Total Items: ${bundleSelections.totalItems} | Total Price: ${formatCurrency(totalPrice, bundleSelections.items[0]?.currencyCode || 'USD')} ${bundleSelections.subscribeAndSave ? ' (Subscription)' : ' (One-time)'}
`; ``` #### 6. Store Normalized Prices When adding items to selections, store normalized prices: ```javascript bundleSelections.items.push({ productId: productId, variantId: variantId, quantity: change, collectionId: collectionId, title: product.title, price: normalizePriceFromCents(variant.price), // Store normalized price, not cents currencyCode: 'USD' // From bundle data structure }); ``` #### 7. Theme-Driven Compare-At Pricing (Online Store) Follow the store’s price presentation. Reuse the theme’s price component and variables rather than custom CSS. If necessary, add semantic markup (a `span` for compare-at) and let theme classes style it. Example (JS) with theme class hook: ```javascript const root = document.getElementById('bundleWidget'); const compareClass = root?.dataset?.priceCompareClass || 'price-compare-at'; function renderThemeAlignedPrice(pricing) { let html = ''; if (pricing.compareAt && pricing.compareAt > pricing.active) { html += `${formatCurrency(pricing.compareAt)} `; } html += `${formatCurrency(pricing.active)}`; return html; } ``` ### Expected Results **One-time Purchase:** ``` $8.00 ``` **Subscription with Discount:** ``` $8.00 $6.40 (1-week) ^^^ ^^^ ^^^ strikethrough active frequency (original) (discounted) ``` ## REQUIRED: Selling Plan Matching Patterns **IMPORTANT: This section is for DYNAMIC BUNDLES only.** For **Fixed Price Bundles**, selling plan matching is not needed \- see: [Fixed Price Bundle Implementation](#fixed-price-bundle-implementation) ### Bundle Data Structure Patterns When working with dynamic bundles, you'll encounter these selling plan allocation patterns in the data returned from `recharge.bundleData.loadBundleData()`: **Selling Plan Allocation Structure** Selling plan allocations have a nested structure where the selling plan information is contained within a `selling_plan` object: **Complete Allocation Structure:** ```json { "selling_plan_allocations": [ { "selling_plan": { "id": 4956258556, "name": "1 week subscription with 20% discount", "options": [...], "price_adjustments": [...] }, "price": 960, "compare_at_price": 1200 } ] } ``` #### Bundle Product Selling Plans The bundle product has selling plan groups with specific IDs: ```json { "id": "59925925417e8d4a3dd372d41cac9b446012ed56", "name": "1 week subscription with 20% discount", "selling_plans": [ { "id": 4956258556, "options": [ { "name": "Order Frequency and Unit", "value": "1-week" } ], "price_adjustments": [ { "value_type": "percentage", "value": 20 } ] } ] } ``` #### Child Product Selling Plan Patterns **Pattern 1: Direct ID Match (Ideal)** ```json // Product: "Udon noodles" - COMPATIBLE { "selling_plan_allocations": [ { "selling_plan": { "id": 4956258556 // ✅ Exact match with bundle selling plan }, "price": 960, "compare_at_price": 1200 } ] } ``` **Pattern 2: Semantic Match (Compatible)** ```json // Product: "Grilled veg tortillas" - COMPATIBLE { "selling_plan_allocations": [ { "selling_plan": { "id": 4960813308 // ❓ Different ID, but same semantics }, "price": 1600, "compare_at_price": 2000 } ], "selling_plan_groups": [ { "selling_plans": [ { "id": 4960813308, "options": [ { "name": "Order Frequency and Unit", "value": "1-week" } // ✅ Same frequency ], "price_adjustments": [ { "value_type": "percentage", "value": 20 } // ✅ Same discount ] } ] } ] } ``` **Pattern 3: Incompatible (Filter Out)** ```json // Product: "Pasta with leek carbonara" - INCOMPATIBLE { "selling_plan_allocations": [ { "selling_plan": { "id": 4889739516 // ❌ Different ID }, "price": 504, "compare_at_price": 720 } ], "selling_plan_groups": [ { "selling_plans": [ { "id": 4889739516, "options": [ { "name": "Order Frequency and Unit", "value": "1-week" } // ✅ Same frequency ], "price_adjustments": [ { "value_type": "percentage", "value": 30 } // ❌ Different discount (30% vs 20%) ] } ] } ] } ``` ### Correct Product Filtering Implementation Based on these patterns, here's the proper filtering logic: #### 1. Get Bundle Selling Plan IDs for Direct Matching ```javascript // Get all selling plan IDs from bundle product (simplified approach) function getBundleSellingPlanIds() { if (!bundleData || !bundleData.selling_plan_groups) { console.log('❌ No bundle selling plan groups found'); return []; } const bundleSellingPlanIds = []; bundleData.selling_plan_groups.forEach(group => { if (group.selling_plans) { group.selling_plans.forEach(plan => { bundleSellingPlanIds.push(plan.id); }); } }); console.log('📋 Bundle selling plan IDs:', bundleSellingPlanIds); return bundleSellingPlanIds; } ``` #### 2. Simplified Product Compatibility Check ```javascript // Check if product has selling plans that match bundle selling plans function hasCompatibleSellingPlans(product, bundleSellingPlanIds) { console.log(`🔍 Checking compatibility for: ${product.title}`); if (!product.variants || bundleSellingPlanIds.length === 0) { console.log(`❌ No variants or bundle selling plans`); return false; } // Check if ANY variant has selling_plan_allocations with bundle selling plan IDs const hasDirectMatch = product.variants.some(variant => { if (!variant.selling_plan_allocations) return false; return variant.selling_plan_allocations.some(allocation => { const isDirectMatch = bundleSellingPlanIds.includes(allocation.selling_plan.id); if (isDirectMatch) { console.log(`✅ Direct match found: selling_plan.id ${allocation.selling_plan.id}`); } return isDirectMatch; }); }); if (hasDirectMatch) { console.log(`✅ ${product.title}: COMPATIBLE (direct match)`); return true; } console.log(`❌ ${product.title}: INCOMPATIBLE (no direct selling plan match)`); return false; } ``` #### 3. Updated Collection Display ```javascript // Display collections and filter products by selling plan compatibility function displayCollections(collectionBindings) { const container = document.getElementById('bundle-collections'); container.innerHTML = ''; // Get bundle selling plan IDs for compatibility checking const bundleSellingPlanIds = getBundleSellingPlanIds(); if (bundleSellingPlanIds.length === 0) { console.warn('⚠️ No bundle selling plans found - cannot filter products'); return; } console.log('🔍 Filtering products based on selling plan compatibility...'); collectionBindings.forEach((binding, index) => { displayCollection(binding, index + 1, container, bundleSellingPlanIds); }); } // Display a single collection with filtered products function displayCollection(binding, index, container, bundleSellingPlanIds) { const collectionData = bundleData.collections[binding.id]; if (!collectionData || !collectionData.products || collectionData.products.length === 0) { console.warn(`Collection ${binding.id} has no products`); return; } // Filter products to only show those with compatible selling plans const originalCount = collectionData.products.length; const compatibleProducts = collectionData.products.filter(product => { return hasCompatibleSellingPlans(product, bundleSellingPlanIds); }); console.log(`📊 Collection "${collectionData.title}": ${compatibleProducts.length}/${originalCount} products have compatible selling plans`); if (compatibleProducts.length === 0) { console.warn(`⚠️ No compatible products found in collection "${collectionData.title}"`); return; } // Create collection section with filtered products const section = document.createElement('div'); section.className = 'collection-section'; section.innerHTML = `

${collectionData.title}

Choose ${binding.quantityMin || 0} - ${binding.quantityMax || '∞'} items

${compatibleProducts.map(product => createProductCard(product, binding.id) ).join('')}
`; 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