# Guide to Recharge Bundles & Custom Bundles Widgets This is an llms.txt file that aims to help developers how to load Recharge bundles configuration and how to build custom bundles widgets. ## Start Here: Determine Your Implementation Path **Answer these 4 questions to find your path:** 1. **What are you doing?** - Creating a new bundle widget from scratch - Troubleshooting/fixing an existing implementation 2. **Where are you building?** - Shopify theme (Online Store) - Headless/custom store 3. **What bundle type?** - **Fixed Price Bundle**: Bundle has set price, selections don't change total - **Dynamic Bundle**: Price changes based on customer selections 4. **What data do you need?** - Basic product info (name, price, image) - Custom data (metafields, tags, etc.) **CRITICAL: Confirm Bundle Type First** - Fixed price bundle or Dynamic bundle - If not specified, STOP and ask directly - Do NOT assume or guess - Supporting both types increases complexity (possible but discouraged) **CRITICAL: Determine Implementation Type** **Online Store (Theme-based):** Use if building within Shopify themes (Liquid templates), no custom product data needed **Headless/Custom:** Use if building headless (custom storefronts, mobile apps, PWAs) OR need custom metafields/rich product data **Based on your answers:** **Path A: Fixed Price Bundle** (New + Either context + Fixed Price + Basic) - **Follow:** Loading Recharge Bundle Settings β†’ Fixed Price Bundle Implementation β†’ Cart Integration **Path B: Dynamic Bundle** (New + Either context + Dynamic + Basic) - **Follow:** Loading Recharge Bundle Settings β†’ Dynamic Bundle Implementation β†’ Cart Integration **Path C: Enhanced Bundle Widget** (New + Either context + Either bundle type + Custom data) - **Follow:** All sections including Advanced Features **Path D: Troubleshooting** (Troubleshooting + Any answers) - **Jump to:** Debugging sections ## How to Use This Guide **Follow your path from above:** - **REQUIRED** sections: Core implementation for all paths - **OPTIONAL** sections: Enhanced features (skip if not needed) - **REFERENCE** sections: Debugging and troubleshooting **When stuck:** Check Production Checklist and Debugging sections --- ## πŸ“š Table of Contents ### ⚑ REQUIRED: Core Implementation - [Loading Recharge Bundle Settings](#loading-recharge-bundle-settings) - [Building Bundle Widgets for Shopify Online Store](#building-bundle-widgets-for-shopify-online-store-theme-based) - [Building Bundle Widgets for Headless/Custom](#building-bundle-widgets-for-headless-custom-implementations) - [Adding Recharge Dynamic Bundles to Cart](#adding-recharge-dynamic-bundles-to-cart) - [Fixed Price Bundle Implementation](#fixed-price-bundle-implementation) ### 🎨 OPTIONAL: Enhanced Features - [Working with Shopify Metafields](#working-with-shopify-metafields-in-bundle-widgets) *(Skip if you don't need custom product data)* - [Dynamic Bundle Price Handling](#dynamic-bundle-price-handling---special-implementation-requirements) - [Real-World Selling Plan Matching](#real-world-selling-plan-matching-patterns) ### πŸ”§ REFERENCE: Advanced Topics - [Production Checklist](#production-checklist--critical-requirements) - [Debugging Selling Plan Issues](#debugging-selling-plan-matching-issues) - [Troubleshooting Guide](#debugging-selling-plan-matching---real-implementation-example) --- ## ⚑ REQUIRED: Loading Recharge Bundle Settings To load bundle configuration from Recharge, use the CDN version of their SDK: 1. **Include the SDK Script** ```html ``` > **πŸ’‘ Note:** Check [https://storefront.rechargepayments.com/client/docs/changelog/](https://storefront.rechargepayments.com/client/docs/changelog/) for the latest SDK version. Update the version number in the URL above if a newer version is available. 2. **Initialize the SDK** ```javascript await recharge.init({ storeIdentifier: 'your-store.myshopify.com', appName: 'Your App Name', appVersion: '1.0.0' }); ``` 3. **Fetch Bundle Settings** There are two methods available for obtaining bundle settings, depending on your implementation type: **For Shopify Online Store (Theme-based) implementations:** - Use `recharge.bundleData.loadBundleData()` - Returns complete bundle data including: - Bundle settings - Full collection and product information - Everything needed to build your widget **For Headless/Custom implementations:** - Use `recharge.cdn.getCDNBundleSettings()` - Returns only bundle settings - You'll need to fetch collections and product information separately using Shopify's GraphQL API ## Building Bundle Widgets for Shopify Online Store (Theme-based) For Shopify theme implementations, Recharge provides a streamlined method that includes all necessary data in a single optimized call. - Do NOT ask for identifiers or data you can derive at runtime - Auto-derive from Liquid product context: - `storeIdentifier`: `{{ shop.permanent_domain }}` - `bundleProductId`: `{{ product.id }}` - `bundleProductHandle`: `{{ product.handle }}` - `bundleVariantId`: `{{ product.selected_or_first_available_variant.id }}` - Do NOT ask for: Shopify store domain, bundle product ID, collection IDs, or selling plan setup confirmations - Load these from `recharge.bundleData.loadBundleData()` and product context ### Block-First (Required for Online Store) To make the widget embeddable anywhere and keep logic reusable, implement it as a theme block and use a thin section wrapper plus a dedicated product template: - Block: All widget logic and UI live in a theme block (type handle aligned with naming, e.g., `bundle-builder`). - Section wrapper: Minimal section that only renders its blocks; used to place the block in templates or presets. No business logic here. - Product template: JSON template that includes the wrapper section with a default instance of the block. #### Block Placement (Theme) - Preferred (theme supports top-level blocks): place the block at `blocks/bundle-builder.liquid`. - Fallback (if `blocks/` directory is not present in the theme): keep a thin wrapper section `sections/bundle-builder.liquid` and put all widget logic/UI in `snippets/bundle-builder.liquid` (treat the snippet as the β€œblock” logic). The section remains thin (no business logic, no network calls). #### File Path Resolution Rules (Theme only) 1) If a `blocks/` directory exists at the theme root β†’ create `blocks/bundle-builder.liquid`. 2) Else β†’ use a fallback with a thin wrapper section and a logic snippet: - `sections/bundle-builder.liquid` (wrapper only) - `snippets/bundle-builder.liquid` (widget logic/UI) Always confirm product template name, section name, and block type before scaffolding. Do/Don’t for Online Store builds: - Do: Put Recharge SDK usage, widget markup, validation, and cart calls in the block. - Do: Keep the section wrapper β€œthin” so the widget can be reused across templates. - Don’t: Implement the widget as a standalone section with all logic. Suggested defaults (customizable): - Template: `product.bundle-builder.json` - Section (thin wrapper): `bundle-builder.liquid` - Block (preferred): `bundle-builder` in `blocks/bundle-builder.liquid` - Fallback (if no `blocks/` directory): keep logic in `snippets/bundle-builder.liquid` and have the thin section render it; document this as temporary and plan to migrate to a block when possible. Fallback exception (document when used): - If the theme does not have a `blocks/` directory or cannot render blocks in the desired template, use the thin section + logic snippet fallback. You must: - Note the limitation. - Keep logic isolated so it can be moved into a block later. ### Setup Workflow for Online Store When building a bundle widget for the online store, follow this recommended setup process: #### 0. Include Required SDK Scripts (must include) Add these script tags before any code that calls `recharge.*` (theme.liquid or at the top of your section). Include the Recharge SDK exactly once. ```html ``` #### 1. Confirm Implementation Details Before creating files, confirm these details with the merchant: **Required Information:** - **Product Template Name**: What should the bundle product template be called? - Default suggestion: `product.bundle-builder.json` - Example alternatives: `product.bundle.json`, `product.meal-bundle.json` - **Section Name**: What should the bundle widget section be called? - Default suggestion: `bundle-builder.liquid` - Example alternatives: `bundle-widget.liquid`, `meal-bundle-builder.liquid` - **Block Name**: What should the bundle widget block type be called? - Default suggestion: `bundle-builder` (keep consistent with the section/template naming) #### 2. Create Product Template Create a product template file `templates/product.[template-name].json` that includes the bundle section and a default instance of the bundle block: ```json { "sections": { "main": { "type": "[section-name]", "blocks": { "bundle": { "type": "bundle-builder" } }, "block_order": ["bundle"], "settings": {} } }, "order": ["main"] } ``` #### 3. Create Bundle Section (Thin Wrapper Only) Create a thin wrapper section `sections/[section-name].liquid` that renders its blocks. Keep widget logic inside the block so the widget is embeddable anywhere. The wrapper must not load SDKs, render UI, or make network calls. Stick to this minimal wrapper pattern and keep the blocks sections as defined here so the merchant can add other block types (no widget logic here): ```liquid {% content_for 'blocks' %} {% schema %} { "name": "Bundle Builder", "settings": [ ], "blocks": [ { "type": "@theme" }, { "type": "@app" } ], "presets": [ { "name": "Bundle Builder" } ] } {% endschema %} ``` #### 4. Apply Template to Bundle Products In Shopify Admin: 1. Go to Products β†’ [Your Bundle Product] 2. In the "Theme templates" section, select your new template 3. Save the product ### Theme Block Implementation Notes (Widget Lives Here) Implement the widget as a theme block (e.g., `blocks/bundle-builder.liquid`). Keep it self-contained and scope styles/IDs per block instance so multiple widgets can coexist. Include the Recharge SDK with an idempotent guard so multiple block instances don’t double-load scripts. - Product context: use `closest.product` where available; fallback to `product`. - Mount node: unique per block instance, e.g., `id="bundle-builder-root-{{ block.id }}"`. - Scoped styles: prefix selectors with a class including `block.id` (e.g., `.bundle-builder--{{ block.id }}`) to avoid collisions. - Block settings: expose width/alignment and apply via data attributes or CSS variables; include `{{ block.shopify_attributes }}`. - External scripts: include required SDKs once; optionally add a guard to avoid duplicate loads if multiple blocks render on a page. Minimal pattern: ```liquid {% liquid assign product = closest.product -%} {% comment %} ...widget implementation here {% endcomment %} {% schema %} { "name": "Bundle Builder", "tag": null, "settings": [ ], "presets": [ { "name": "Bundle Builder", "category": "t:categories.product" } ] } {% endschema %} ``` Always implement the block first, then the section, then the product template. ### Using recharge.bundleData.loadBundleData() This method provides complete bundle data including products and collections in one request: #### 1. Method Overview ```javascript // Load complete bundle data including products and collections const bundleData = await recharge.bundleData.loadBundleData(externalProductId); ``` #### 2. Implementation Pattern ```javascript async function initializeBundleWidget() { try { // Guard early in JS examples too if (!window.recharge || !recharge.init) { throw new Error('Recharge SDK not loaded. Include recharge-client script before this code.'); } // Initialize Recharge SDK await recharge.init({ storeIdentifier: 'your-store.myshopify.com', appName: 'Bundle Widget', appVersion: '1.0.0' }); // Load complete bundle data (includes products and collections) const bundleData = await recharge.bundleData.loadBundleData('8138138353916'); console.log('Bundle data loaded:', bundleData); // Use bundleData to build your widget } catch (error) { console.error('Error loading bundle data:', error); } } ``` #### Price Normalization (Online Store only) Prices returned by `recharge.bundleData.loadBundleData()` are in cents. Add this tiny helper and use it wherever you read `variant.price`, `variant.compare_at_price`, or `selling_plan_allocations[*].price` in the Online Store path. GraphQL implementations already use `moneyV2` and do not need this. ```javascript function normalizePriceFromCents(priceInCents) { return priceInCents / 100; } ``` #### 3. Bundle Data Structure The `loadBundleData()` method returns a `PublicBundleData` object with the following structure: ```typescript interface PublicBundleData { /** External product ID (from Shopify) */ id: string; title: string; handle: string; /** Product options (size, color, etc.) */ options: ProductOption[]; default_variant_id: string | null; /** Whether the bundle is available for purchase */ available_for_sale: boolean; /** Whether the bundle requires a selling plan (subscription) */ requires_selling_plan: boolean; bundle_settings: BundleSettings; variants: BundleVariant[]; selling_plan_groups: SellingPlanGroup[]; /** Collections that contain products for this bundle */ collections: Record; addons: AddonsSection | null; cross_sells: CrossSellsSection | null; incentives: Incentives | null; } ``` #### 4. Key Components **Bundle Variants:** ```typescript interface BundleVariant { /** External variant ID (from Shopify) */ id: string; title: string; price: number; compare_at_price: number | null; image: string; available_for_sale: boolean; options: string[]; requires_selling_plan: boolean; selling_plan_allocations: SellingPlanAllocation[]; ranges: QuantityRange[]; collections: CollectionBinding[]; default_selections: DefaultSelection[]; position: number; } ``` **Quantity Constraints:** ```typescript interface QuantityRange { /** Minimum quantity (0 = no minimum) */ min: number; /** Maximum quantity (null = no maximum) */ max: number | null; } interface CollectionBinding extends QuantityRange { id: string; source_platform: 'shopify'; } ``` **Collections with Products:** ```typescript interface Collection { id: string; title: string; handle: string; products: Product[]; } interface Product { /** External product ID */ id: string; title: string; description: string | null; handle: string; tags: string[]; /** Whether the product is available for sale */ available_for_sale: boolean; featured_image: string | null; images: string[]; price_range: PriceRange; compare_at_price_range: PriceRange; options: string[]; variants: Variant[]; requires_selling_plan: boolean; selling_plan_groups: SellingPlanGroup[]; } ``` #### 5. Working with Bundle Data **Extract Variants:** ```javascript function populateVariantPicker(bundleData) { const select = document.getElementById('bundle-variant-select'); bundleData.variants.forEach((variant, index) => { if (variant.available_for_sale) { const option = document.createElement('option'); option.value = variant.id; option.textContent = variant.title || `Bundle Option ${index + 1}`; select.appendChild(option); } }); } ``` **Display Collections:** ```javascript function displayCollections(variant, bundleData) { const container = document.getElementById('bundle-collections'); variant.collections.forEach((binding, index) => { // Get collection data from bundleData.collections const collectionData = bundleData.collections[binding.id]; if (collectionData && collectionData.products.length > 0) { const section = document.createElement('div'); section.innerHTML = `

${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:** ```typescript interface PublicBundleData { // Subscription requirements requires_selling_plan: boolean; // If true, only subscription purchases allowed // Available subscription options selling_plan_groups: SellingPlanGroup[]; // Product variants with selling plan allocations variants: BundleVariant[]; } interface SellingPlanGroup { id: string; name: string; merchant_code: string; app_id: string; selling_plans: SellingPlan[]; } interface SellingPlan { id: string; name: string; // e.g., "Deliver every 2 weeks", "Monthly delivery" description: string; options: SellingPlanOption[]; recurring_deliveries: boolean; price_adjustments: PriceAdjustment[]; } ``` **Smart Purchase Option Detection:** ```javascript function analyzeSubscriptionOptions(bundleData) { // Check if one-time purchases are allowed const oneTimeAllowed = !bundleData.requires_selling_plan; // Extract available selling plans const availableSellingPlans = []; if (bundleData.selling_plan_groups && bundleData.selling_plan_groups.length > 0) { bundleData.selling_plan_groups.forEach(group => { group.selling_plans.forEach(plan => { availableSellingPlans.push({ id: plan.id, name: plan.name, description: plan.description, options: plan.options || [] }); }); }); } return { oneTimeAllowed, availableSellingPlans }; } ``` **Subscribe & Save Toggle Implementation:** ```javascript function setupSubscriptionToggle(bundleData) { const { oneTimeAllowed, availableSellingPlans } = analyzeSubscriptionOptions(bundleData); // Show toggle only if BOTH one-time AND subscription are available const showToggle = oneTimeAllowed && availableSellingPlans.length > 0; if (showToggle) { // Show toggle switch for user choice displaySubscriptionToggle(); } else if (!oneTimeAllowed) { // Bundle requires subscription - auto-enable enableSubscriptionMode(); autoSelectFirstSellingPlan(); } // If only one-time available, no UI needed } ``` **Dynamic Pricing Based on Selection:** ```javascript function calculateProductPricing(product, variant, bundleSelections) { // Online Store bundle data is in cents; normalize to dollars const oneTimePrice = normalizePriceFromCents(variant.price); let subscriptionPrice = oneTimePrice; // If subscription is selected and selling plans exist if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) { // Find selling plan allocation for this variant const sellingPlanAllocation = variant.selling_plan_allocations?.find( allocation => allocation.selling_plan.id === bundleSelections.selectedSellingPlan.id ); if (sellingPlanAllocation) { subscriptionPrice = normalizePriceFromCents(sellingPlanAllocation.price); } } return { oneTime: oneTimePrice, subscription: subscriptionPrice, active: bundleSelections.subscribeAndSave ? subscriptionPrice : oneTimePrice, isSubscription: bundleSelections.subscribeAndSave }; } ``` **State Management:** ```javascript // Use null for one-time, object for subscription let bundleSelections = { selectedSellingPlan: null, // null = one-time, object = subscription plan subscribeAndSave: false, // ... other properties }; // Toggle between one-time and subscription function toggleSubscription(isSubscription) { bundleSelections.subscribeAndSave = isSubscription; if (isSubscription && availableSellingPlans.length > 0) { // Auto-select first plan if none selected bundleSelections.selectedSellingPlan = bundleSelections.selectedSellingPlan || availableSellingPlans[0]; } else { // One-time selected bundleSelections.selectedSellingPlan = null; } updateAllProductPricing(); updateBundleSummary(); } ``` **Subscribe & Save Toggle UI Implementation:** Create a beautiful, prominent toggle that clearly shows subscription benefits: ```html ``` **Toggle Styling (Professional Switch Design):** ```css /* Subscribe & Save Toggle */ .subscription-toggle { background: rgb(var(--color-background, 255 255 255)); border: 2px solid rgb(var(--color-button, 0 123 255)); border-radius: var(--border-radius, 8px); padding: var(--spacing-lg, 24px); margin-bottom: var(--spacing-lg, 24px); box-shadow: 0 2px 8px rgba(var(--color-shadow, 0 0 0), 0.1); } .subscription-toggle__content { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--spacing-md, 16px); padding: var(--spacing-sm, 8px) 0; } .subscription-toggle__label { font-weight: var(--font-weight-bold, 600); font-size: var(--font-size-lg, 1.1rem); color: rgb(var(--color-button, 0 123 255)); display: flex; align-items: center; gap: var(--spacing-xs, 8px); } .subscription-toggle__label::before { content: "πŸ’°"; font-size: 1.2em; } /* Toggle Switch Styles */ .subscription-toggle__switch { position: relative; display: inline-block; width: 64px; height: 32px; margin: var(--spacing-xs, 4px) 0 0 0; flex-shrink: 0; } .subscription-toggle__switch input { opacity: 0; width: 0; height: 0; } .subscription-toggle__slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(145deg, #e2e8f0, #cbd5e0); transition: all 0.3s ease; border-radius: 32px; border: 2px solid #cbd5e0; box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); } .subscription-toggle__slider:before { position: absolute; content: ''; height: 24px; width: 24px; left: 4px; bottom: 2px; background: linear-gradient(145deg, #ffffff, #f1f5f9); transition: all 0.3s ease; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.1); } .subscription-toggle__switch input:checked + .subscription-toggle__slider { background: linear-gradient(145deg, rgb(var(--color-button, 0 123 255)), #0066cc); border-color: rgb(var(--color-button, 0 123 255)); box-shadow: inset 0 2px 4px rgba(0,0,0,0.2), 0 0 0 2px rgba(var(--color-button, 0 123 255), 0.2); } .subscription-toggle__switch input:checked + .subscription-toggle__slider:before { transform: translateX(32px); background: linear-gradient(145deg, #ffffff, #f8fafc); box-shadow: 0 2px 8px rgba(0,0,0,0.3); } /* Dynamic Benefit Text */ .subscription-toggle__benefit { font-size: var(--font-size-sm, 0.9rem); color: rgb(var(--color-foreground-secondary, 102 102 102)); font-weight: var(--font-weight-normal, 400); font-style: italic; } ``` **Dynamic Benefit Text Implementation:** ```javascript // Update subscription benefit text with dynamic discount info function updateSubscriptionBenefitText() { const benefitText = document.getElementById('subscription-benefit-text'); if (!benefitText) return; if (availableSellingPlans.length > 0) { // Find the largest discount percentage and frequency let maxDiscount = 0; let frequency = ''; availableSellingPlans.forEach(plan => { // Extract frequency from plan options if (plan.options && plan.options.length > 0) { plan.options.forEach(option => { if (option.name === 'Order Frequency and Unit' && option.values && option.values.length > 0) { frequency = option.values[0].replace('-', ' '); } }); } // Find price adjustments in bundle data if (bundleData && bundleData.selling_plan_groups) { bundleData.selling_plan_groups.forEach(group => { if (group.selling_plans) { group.selling_plans.forEach(sp => { if (sp.id.toString() === plan.id.toString() && sp.price_adjustments && sp.price_adjustments.length > 0) { sp.price_adjustments.forEach(adjustment => { if (adjustment.value_type === 'percentage' && adjustment.value > maxDiscount) { maxDiscount = adjustment.value; } }); } }); } }); } }); if (maxDiscount > 0) { benefitText.textContent = `Get ${maxDiscount}% off with ${frequency || 'subscription'} delivery`; } else { benefitText.textContent = `Save with ${frequency || 'subscription'} delivery`; } } else { benefitText.textContent = 'Select subscription for savings'; } } ``` **Critical: Selling Plan Matching for Dynamic vs Fixed Bundles** There's a crucial distinction between fixed priced bundles and dynamically priced bundles when handling selling plans: **Fixed Priced Bundles:** - Use the selling plan from the bundle product level - All items inherit the same selling plan ID - Simple implementation: apply bundle product's selling plan to all selections **Dynamic Priced Bundles:** - Each child product may have different selling plan IDs - Must find equivalent selling plans that match the bundle product's semantics - Match based on "Order Frequency and Unit" and price adjustment values - **Required for this implementation** since we're building dynamic bundle widgets **Why This Matters:** If you simply use the bundle product's selling plan ID for all child items, the cart may fail or apply incorrect pricing/discounts to individual products. Each child product must use its own selling plan ID that has the same subscription terms (frequency and discount) as the bundle product. **Implementation Requirement:** This selling plan matching logic is **essential** for dynamic bundles where: - Products come from different collections - Each product has its own set of selling plans - The bundle product acts as a template for subscription terms **Enhanced Selling Plan Matching Logic:** ```javascript // Find matching selling plan for child product using two-step approach function findMatchingSellingPlan(bundleSellingPlan, childProductSellingPlans, productVariants = []) { if (!bundleSellingPlan || !childProductSellingPlans || childProductSellingPlans.length === 0) { return null; } // STEP 1: Try direct selling_plan_allocation match first (most accurate) // Check if any product variants have selling_plan_allocations that directly match the bundle selling plan ID if (productVariants && productVariants.length > 0) { for (const variant of productVariants) { if (variant.selling_plan_allocations) { const directMatch = variant.selling_plan_allocations.find( allocation => allocation.selling_plan.id === bundleSellingPlan.id ); if (directMatch) { console.log(`βœ… Direct selling_plan_allocation match found for selling plan ${bundleSellingPlan.id}`); return bundleSellingPlan; // Return the bundle selling plan since it's directly supported } } } } console.log(`⚠️ No direct selling_plan_allocation match for ${bundleSellingPlan.id}, falling back to heuristic matching`); // STEP 2: Fall back to heuristic matching (frequency + price adjustment) // Extract criteria from bundle selling plan const bundleFrequency = bundleSellingPlan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; const bundlePriceAdjustment = bundleSellingPlan.price_adjustments?.[0]; if (!bundleFrequency || !bundlePriceAdjustment) { console.log(`❌ Bundle selling plan missing frequency or price adjustment data`); return null; } // Find matching child selling plan with same frequency and discount const matchingPlan = childProductSellingPlans.find(childPlan => { // Check frequency match (e.g., "1-week") const childFrequency = childPlan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; if (childFrequency !== bundleFrequency) return false; // Check price adjustment match (e.g., percentage: 20%) const childPriceAdjustment = childPlan.price_adjustments?.[0]; if (!childPriceAdjustment || !bundlePriceAdjustment) return false; return ( childPriceAdjustment.value_type === bundlePriceAdjustment.value_type && childPriceAdjustment.value === bundlePriceAdjustment.value ); }); if (matchingPlan) { console.log(`βœ… Heuristic match found: ${matchingPlan.id} for bundle plan ${bundleSellingPlan.id}`); } else { console.log(`❌ No heuristic match found for bundle plan ${bundleSellingPlan.id}`); } return matchingPlan; } // Get selling plan data from bundle data function getBundleSellingPlanData(sellingPlanId) { if (!bundleData || !bundleData.selling_plan_groups) return null; for (const group of bundleData.selling_plan_groups) { if (group.selling_plans) { const plan = group.selling_plans.find(sp => sp.id.toString() === sellingPlanId.toString()); if (plan) return plan; } } return null; } // Get child product selling plans from bundle data function getChildProductSellingPlans(productId) { if (!bundleData || !bundleData.collections) return []; // Find the product in collections and get its selling plan groups for (const collectionId in bundleData.collections) { const collection = bundleData.collections[collectionId]; if (collection.products) { const product = collection.products.find(p => p.id === productId); if (product && product.selling_plan_groups) { // Extract all selling plans from all groups const allSellingPlans = []; product.selling_plan_groups.forEach(group => { if (group.selling_plans) { allSellingPlans.push(...group.selling_plans); } }); return allSellingPlans; } } } return []; } // Get child product variants from bundle data (needed for selling_plan_allocations check) function getChildProductVariants(productId) { if (!bundleData || !bundleData.collections) return []; // Find the product in collections and get its variants for (const collectionId in bundleData.collections) { const collection = bundleData.collections[collectionId]; if (collection.products) { const product = collection.products.find(p => p.id === productId); if (product && product.variants) { return product.variants; } } } return []; } ``` ### Benefits of the Enhanced Two-Step Approach **Why This Approach is Superior:** 1. **Direct Allocation Priority**: First checks if the child product already has a `selling_plan_allocation` for the exact bundle selling plan ID - this is the most accurate match possible 2. **Fallback Security**: If no direct allocation exists, falls back to heuristic matching based on frequency and discount semantics 3. **Performance**: Direct ID matching is faster than semantic comparison 4. **Accuracy**: Eliminates potential mismatches from semantic interpretation differences 5. **Future-Proof**: Works with any selling plan structure that Shopify/Recharge might introduce ### Simplified Approach for Direct ID Matching (Recommended) The **simplified direct ID matching approach** is recommended over complex heuristic matching: 1. **Product Filtering**: Only show products that have `selling_plan_allocations` with IDs that match bundle selling plan IDs 2. **Cart Integration**: Use the bundle selling plan ID directly for all compatible products 3. **Eliminates Edge Cases**: Removes products with different discount percentages or incompatible terms 4. **Simpler Implementation**: Reduces complexity and potential bugs from semantic matching 5. **Better User Experience**: Users only see products that definitely work with the bundle **When to Use Each Approach:** - **Simplified Direct Matching**: When all compatible products share the same selling plan IDs as the bundle (most common) - **Enhanced Two-Step**: When products have different selling plan IDs but equivalent semantics (frequency + discount) **Expected Debug Output Patterns:** **Success with Direct Match:** ``` βœ… Direct selling_plan_allocation match found for selling plan 4956258556 Found matching selling plan for product 7823764455676: { bundlePlan: 4956258556, childPlan: 4956258556, method: "direct_allocation", frequency: "1-week" } ``` **Success with Heuristic Fallback:** ``` ⚠️ No direct selling_plan_allocation match for 4956258556, falling back to heuristic matching βœ… Heuristic match found: 4864737532 for bundle plan 4956258556 Found matching selling plan for product 7823764455676: { bundlePlan: 4956258556, childPlan: 4864737532, method: "heuristic_match", frequency: "1-week" } ``` **Failure (Both Methods):** ``` ⚠️ No direct selling_plan_allocation match for 4956258556, falling back to heuristic matching ❌ No heuristic match found for bundle plan 4956258556 No matching selling plan found for product 7823764455676, falling back to bundle selling plan ``` **Cart Integration with Intelligent Selling Plan Matching:** ```javascript async function addBundleToCart(bundleSelections) { const bundleProductData = { productId: bundleProductId, variantId: bundleSelections.variant.id, handle: bundleProductHandle }; // Include selling plan if subscription is selected if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) { bundleProductData.sellingPlan = bundleSelections.selectedSellingPlan.id; } const bundle = { externalVariantId: bundleSelections.variant.id, externalProductId: bundleProductId, selections: bundleSelections.items.map(item => { const selection = { collectionId: item.collectionId, externalProductId: item.productId, externalVariantId: item.variantId, quantity: item.quantity }; // For dynamic bundles, find matching selling plan for each child item if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) { // Get the bundle selling plan data const bundleSellingPlanData = getBundleSellingPlanData(bundleSelections.selectedSellingPlan.id); if (bundleSellingPlanData) { // Get child product's available selling plans and variants const childSellingPlans = getChildProductSellingPlans(item.productId); const childProductVariants = getChildProductVariants(item.productId); // Find matching selling plan using enhanced two-step approach const matchingSellingPlan = findMatchingSellingPlan( bundleSellingPlanData, childSellingPlans, childProductVariants ); if (matchingSellingPlan) { selection.sellingPlan = matchingSellingPlan.id; console.log(`Found matching selling plan for product ${item.productId}:`, { bundlePlan: bundleSellingPlanData.id, childPlan: matchingSellingPlan.id, method: matchingSellingPlan.id === bundleSellingPlanData.id ? 'direct_allocation' : 'heuristic_match', frequency: bundleSellingPlanData.options?.find(opt => opt.name === "Order Frequency and Unit")?.value }); } else { console.warn(`No matching selling plan found for product ${item.productId}, falling back to bundle selling plan`); selection.sellingPlan = bundleSelections.selectedSellingPlan.id; } } else { // Fallback to bundle selling plan if no data found selection.sellingPlan = bundleSelections.selectedSellingPlan.id; } } return selection; }) }; // Get cart items from Recharge SDK (handles selling plans automatically) const cartItems = await recharge.bundle.getDynamicBundleItems(bundle, bundleProductData.handle); // Add to cart using Ajax Cart API await fetch('/cart/add.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: cartItems }) }); } ``` **Example Selling Plan Matching:** If the bundle product has this selling plan: ```json { "id": 4956258556, "name": "1 week subscription with 20% discount", "options": [ { "name": "Order Frequency and Unit", "value": "1-week" } ], "price_adjustments": [ { "value_type": "percentage", "value": 20 } ] } ``` The function will look for child product selling plans with: - βœ… Same frequency: `"1-week"` - βœ… Same discount: `percentage: 20` And select the matching selling plan ID, even if it's different (e.g., `4833706236` vs `4956258556`). **⚠️ Important: Debugging Selling Plan Matching** When implementing selling plan matching, it's crucial to verify that the logic works correctly. A common bug occurs when child products get assigned selling plans with different frequencies than the bundle product. **Debugging Strategy:** 1. **Add comprehensive logging** to track selling plan selection: ```javascript console.log(`πŸ” DEBUGGING: findMatchingSellingPlan for product ${productId}`); console.log(`🎯 Bundle criteria to match:`, { bundleSellingPlanId: bundleSellingPlan.id, bundleFrequency, bundlePriceAdjustment }); ``` 2. **Prevent cart submission** during debugging: ```javascript // 🚨 DEBUG MODE: Log bundle data and stop before cart submission console.log('🚨 DEBUG MODE: Stopping before cart submission to debug selling plans'); console.log('πŸ“¦ Final bundle object being sent to Recharge SDK:', JSON.stringify(bundle, null, 2)); // πŸ›‘ STOP HERE - Don't actually add to cart in debug mode console.log('πŸ›‘ STOPPING: Debug mode active - cart submission prevented'); return; ``` 3. **Validate cart results** by checking the actual selling plans assigned: ```javascript rechargeCartItems.forEach((item, index) => { console.log(`Cart item ${index + 1}:`, { variantId: item.id, sellingPlanId: item.selling_plan_allocation?.selling_plan?.id, frequency: item.selling_plan_allocation?.selling_plan?.options?.find(opt => opt.name === "Order Frequency and Unit")?.value }); }); ``` **Example Bug:** - User selects: "1 week subscription" - Expected result: All items should have selling plans with `"1-week"` frequency - Actual bug: Some items get `"2-week"` frequency instead - Root cause: Matching logic fails to find correct selling plans for some products **Fix Process:** 1. Add debug logging to identify which products are failing to match 2. Verify that child products actually have the required selling plans available 3. Check that matching criteria (frequency + price adjustment) are being compared correctly 4. Ensure fallback logic doesn't inadvertently assign wrong selling plans **Important: Product Filtering for Selling Plan Compatibility** To ensure a consistent user experience, the bundle widget should only display products that have compatible selling plans with the bundle product. **Filtering Logic:** ```javascript // Get bundle selling plan requirements (all selling plans the bundle supports) function getBundleSellingPlanRequirements() { if (!bundleData || !bundleData.selling_plan_groups) return []; const allBundleSellingPlans = []; bundleData.selling_plan_groups.forEach(group => { if (group.selling_plans) { allBundleSellingPlans.push(...group.selling_plans); } }); // Extract the frequency and discount requirements const requirements = allBundleSellingPlans.map(plan => { const frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; const priceAdjustment = plan.price_adjustments?.[0]; return { id: plan.id, name: plan.name, frequency, priceAdjustment: priceAdjustment ? { value_type: priceAdjustment.value_type, value: priceAdjustment.value } : null }; }).filter(req => req.frequency && req.priceAdjustment); // Only include complete requirements return requirements; } // Check if a product has compatible selling plans with the bundle function hasCompatibleSellingPlans(product, bundleRequirements) { if (!product.selling_plan_groups || bundleRequirements.length === 0) { return false; } // Get all selling plans for this product const productSellingPlans = []; product.selling_plan_groups.forEach(group => { if (group.selling_plans) { productSellingPlans.push(...group.selling_plans); } }); // Check if product has ALL required selling plans const compatibleCount = bundleRequirements.filter(requirement => { return productSellingPlans.find(plan => { const frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; const priceAdjustment = plan.price_adjustments?.[0]; return frequency === requirement.frequency && priceAdjustment?.value_type === requirement.priceAdjustment?.value_type && priceAdjustment?.value === requirement.priceAdjustment?.value; }); }).length; return compatibleCount === bundleRequirements.length; } ``` **Example Filtering Scenarios:** **Bundle Product Selling Plans:** - 1-week subscription with 20% discount - 2-week subscription with 15% discount **Product A (βœ… Compatible):** - 1-week subscription with 20% discount - 2-week subscription with 15% discount - 3-week subscription with 10% discount β†’ **Shows in grid** (has all required plans plus additional) **Product B (❌ Incompatible):** - 1-week subscription with 20% discount - 3-week subscription with 10% discount β†’ **Hidden from grid** (missing 2-week 15% discount) **Product C (❌ Incompatible):** - 1-week subscription with 30% discount - 2-week subscription with 15% discount β†’ **Hidden from grid** (1-week discount doesn't match) **Implementation in Product Display:** ```javascript // Filter products before displaying const bundleRequirements = getBundleSellingPlanRequirements(); const compatibleProducts = collectionData.products.filter(product => { return hasCompatibleSellingPlans(product, bundleRequirements); }); // Only display compatible products if (compatibleProducts.length > 0) { // Show filtered products } else { // Show empty collection message } ``` **Why This Matters:** 1. **Prevents Subscription Failures**: Users can't select products that don't support their chosen subscription frequency 2. **Consistent Experience**: All products in a bundle will have the same subscription options available 3. **Clear User Communication**: Users understand why certain products aren't available 4. **Reduces Support Issues**: Eliminates cart failures due to incompatible selling plans **Complete Toggle Logic Implementation:** ```javascript // Setup subscription toggle functionality function setupSubscriptionToggle() { const subscriptionToggle = document.getElementById('subscription-toggle'); const subscribeCheckbox = document.getElementById('subscribe-save-checkbox'); const sellingPlanSelector = document.getElementById('selling-plan-selector'); const sellingPlanSelect = document.getElementById('selling-plan-select'); // Only show toggle if both one-time and subscription options are available const showToggle = oneTimeAllowed && availableSellingPlans.length > 0; if (showToggle) { // Show toggle for user choice subscriptionToggle.style.display = 'block'; subscribeCheckbox.disabled = false; sellingPlanSelector.style.display = 'none'; // Initially hidden until checkbox is checked populateSellingPlanOptions(); } else if (!oneTimeAllowed && availableSellingPlans.length > 0) { // Bundle requires subscription - auto-enable and show toggle subscriptionToggle.style.display = 'block'; subscribeCheckbox.checked = true; subscribeCheckbox.disabled = true; bundleSelections.subscribeAndSave = true; sellingPlanSelector.style.display = 'block'; populateSellingPlanOptions(); // Auto-select first selling plan if (availableSellingPlans.length > 0) { bundleSelections.selectedSellingPlan = availableSellingPlans[0]; sellingPlanSelect.value = availableSellingPlans[0].id; } } else { // No subscription options available - hide toggle subscriptionToggle.style.display = 'none'; } // Subscribe checkbox event listener subscribeCheckbox.addEventListener('change', function() { bundleSelections.subscribeAndSave = this.checked; if (this.checked) { sellingPlanSelector.style.display = 'block'; // Auto-select first selling plan if none selected if (!bundleSelections.selectedSellingPlan && availableSellingPlans.length > 0) { bundleSelections.selectedSellingPlan = availableSellingPlans[0]; sellingPlanSelect.value = availableSellingPlans[0].id; } } else { sellingPlanSelector.style.display = 'none'; bundleSelections.selectedSellingPlan = null; sellingPlanSelect.value = ''; } // Update pricing and summary updateAllProductPricing(); updateBundleSummary(); }); // Selling plan select event listener sellingPlanSelect.addEventListener('change', function() { const selectedPlanId = this.value; if (selectedPlanId) { bundleSelections.selectedSellingPlan = availableSellingPlans.find(plan => plan.id === selectedPlanId); } else { bundleSelections.selectedSellingPlan = null; } // Update pricing and summary updateAllProductPricing(); updateBundleSummary(); }); } // Populate selling plan options in dropdown function populateSellingPlanOptions() { const sellingPlanSelect = document.getElementById('selling-plan-select'); if (!sellingPlanSelect) { console.error('selling-plan-select element not found!'); return; } // Clear existing options (except the default) sellingPlanSelect.innerHTML = ''; availableSellingPlans.forEach(plan => { const option = document.createElement('option'); option.value = plan.id; option.textContent = plan.name; if (plan.description) { option.title = plan.description; } sellingPlanSelect.appendChild(option); }); } ``` **Smart State Management:** ```javascript // State management - use null for one-time, object for subscription let bundleSelections = { selectedSellingPlan: null, // null = one-time, object = subscription plan subscribeAndSave: false, // ... other properties }; // Toggle between one-time and subscription function toggleSubscription(isSubscription) { bundleSelections.subscribeAndSave = isSubscription; if (isSubscription && availableSellingPlans.length > 0) { // Auto-select first plan if none selected bundleSelections.selectedSellingPlan = bundleSelections.selectedSellingPlan || availableSellingPlans[0]; } else { // One-time selected bundleSelections.selectedSellingPlan = null; } updateAllProductPricing(); updateBundleSummary(); } ``` **Dynamic Product Pricing with Subscriptions:** ```javascript // Calculate pricing based on subscription selection function calculateProductPricing(product, variant, bundleSelections) { const oneTimePrice = variant.price; let subscriptionPrice = oneTimePrice; // If subscription is selected and selling plans exist if (bundleSelections.subscribeAndSave && bundleSelections.selectedSellingPlan) { // Find selling plan allocation for this variant const sellingPlanAllocation = variant.selling_plan_allocations?.find( allocation => allocation.selling_plan.id === bundleSelections.selectedSellingPlan.id ); if (sellingPlanAllocation) { subscriptionPrice = sellingPlanAllocation.price; } } return { oneTime: oneTimePrice, subscription: subscriptionPrice, active: bundleSelections.subscribeAndSave ? subscriptionPrice : oneTimePrice, isSubscription: bundleSelections.subscribeAndSave }; } // Update all product cards with current pricing function updateAllProductPricing() { // Find all product cards and update their pricing displays const root = document.getElementById('bundleWidget'); const priceCompareClass = root?.dataset?.priceCompareClass || 'price-compare-at'; document.querySelectorAll('.bundle-product-card').forEach(card => { const productId = card.dataset.productId; const variantId = card.dataset.variantId; // Find product and variant data const product = findProductInCollections(productId); const variant = product?.variants.find(v => v.id === variantId); if (product && variant) { const pricing = calculateProductPricing(product, variant, bundleSelections); // Update price display const priceElement = card.querySelector('.bundle-product-price'); if (priceElement) { priceElement.innerHTML = ` ${pricing.isSubscription && pricing.subscription !== pricing.oneTime ? `$${pricing.oneTime.toFixed(2)}` : '' } $${pricing.active.toFixed(2)} ${pricing.isSubscription ? '/ delivery' : ''} `; } } }); } ``` **Benefits of This Approach:** - βœ… **Smart Detection**: Automatically detects available purchase options - βœ… **Flexible UI**: Shows toggle only when both options available - βœ… **Auto-Configuration**: Handles subscription-required bundles automatically - βœ… **Dynamic Pricing**: Shows correct prices based on selection - βœ… **Professional Design**: Beautiful toggle switch with clear visual feedback - βœ… **Accessible**: Proper ARIA labels and keyboard navigation - βœ… **Proper Integration**: Works seamlessly with Recharge SDK #### 7. Complete Implementation Example ```javascript document.addEventListener('DOMContentLoaded', async function() { const CONFIG = { storeIdentifier: 'your-store.myshopify.com', bundleProductId: 'gid://shopify/Product/8138138353916', // From closest.product appName: 'Bundle Widget', appVersion: '1.0.0' }; let bundleData = null; let bundleSelections = { variantId: null, variant: null, items: [], totalItems: 0, isValid: false, errors: [] }; async function initializeBundleWidget() { try { // Initialize Recharge SDK await recharge.init(CONFIG); // Load complete bundle data bundleData = await recharge.bundleData.loadBundleData(CONFIG.bundleProductId); if (!bundleData || !bundleData.variants || bundleData.variants.length === 0) { throw new Error('No bundle variants found'); } // Setup widget populateVariantPicker(bundleData); // Auto-select first variant if (bundleData.variants.length === 1) { selectVariant(bundleData.variants[0]); } } catch (error) { console.error('Error initializing bundle widget:', error); } } function selectVariant(variant) { bundleSelections.variant = variant; bundleSelections.variantId = variant.id; // Display collections for this variant displayCollections(variant, bundleData); } // Start the widget initializeBundleWidget(); }); ``` #### 7. Benefits of loadBundleData() 1. **Single API Call**: All data in one request, no need for multiple GraphQL queries 2. **Optimized Performance**: Faster loading compared to separate collection fetches 3. **Complete Data**: Includes products, variants, images, pricing, and constraints 4. **Simplified Code**: No need for Shopify Storefront API client or access tokens 5. **Real-time Data**: Always returns current product availability and pricing 6. **Built-in Optimization**: Cached and optimized by Recharge infrastructure ### Key Features 1. **Online Store Optimized**: Specifically designed for Shopify theme implementations 2. **Product Context**: Use `closest.product.id` when implementing in Shopify blocks 3. **No Access Tokens**: No need for Storefront API access tokens 4. **Real-time Data**: Product availability and pricing are always current 5. **Single Request**: All data loaded in one optimized call 6. **Error Handling**: Built-in error handling and retry mechanisms 7. **Subscription Support**: Built-in support for one-time and subscription purchases with selling plans **Note for GraphQL/Headless Implementations:** The subscription handling patterns documented above can also be applied to GraphQL implementations by: - Using `recharge.cdn.getCDNBundleSettings()` to get selling plan information - Querying Shopify Storefront API for selling plan allocations on product variants - Following the same smart detection and state management patterns - Including selling plans in the cart integration as shown above This approach simplifies bundle widget development for Shopify online stores while providing excellent performance and reliable data access. ## ⚑ REQUIRED: Building Bundle Widgets for Headless/Custom Implementations For headless implementations, custom storefronts, or when you need maximum flexibility in data handling, use the GraphQL approach with Recharge CDN settings. ### Setup Workflow for Headless/Custom When building a bundle widget for headless or custom implementations, follow this setup process: #### 1. Gather Required Configuration Before implementing the widget, collect these essential details from the merchant: **Required Information:** - **Bundle Type to Implement**: Choose one - Fixed Price (single bundle line item; add ONLY bundle product with `_rb_id`) - Dynamic (individual items added; price sums by selections) - **Store Domain**: The full Shopify store domain - Example: `your-store.myshopify.com` - Format: `https://your-store.myshopify.com` (include https://) - **Storefront Access Token**: Shopify Storefront API access token - Example: `shpat_xxxxxx` - Required scope: Storefront API access with product and collection read permissions - **Bundle Product ID**: The Shopify product ID for the bundle - Example: `gid://shopify/Product/8138138353916` or just `8138138353916` **Example Questions to Ask:** ``` "I'll create a bundle widget for your custom implementation. I need these details: First, which bundle type should this widget support? - Fixed Price (add only the bundle product with _rb_id) - Dynamic (add individual child items) If you're not sure, I can auto-detect after loading the bundle settings. 1. Your Shopify store domain (e.g., your-store.myshopify.com) 2. A Shopify Storefront API access token (starts with 'shpat_') 3. The product ID of your bundle product Do you have a Storefront API access token set up? If not, I can guide you through creating one." ``` #### 2. Access Token Setup Guide If the merchant needs to create a Storefront API access token: 1. **Go to Shopify Admin** β†’ Apps β†’ App development β†’ Private apps 2. **Create a private app** (or use existing one) 3. **Enable Storefront API access** 4. **Configure permissions**: - Read products, variants, and inventory - Read collections - Read metafields (if using custom metafields) 5. **Copy the Storefront access token** #### 3. Configuration Structure Ensure your widget implementation includes easily configurable settings: ```javascript // Configuration object - make this easily customizable const CONFIG = { // Store configuration storeDomain: 'https://your-store.myshopify.com', // CUSTOMIZE: Replace with actual store domain storeIdentifier: 'your-store.myshopify.com', // CUSTOMIZE: Replace with store domain (without https) // API configuration storefrontAccessToken: 'shpat_your-token-here', // CUSTOMIZE: Replace with actual access token // Bundle configuration bundleProductId: '8138138353916', // CUSTOMIZE: Replace with actual bundle product ID // App configuration appName: 'Bundle Widget', appVersion: '1.0.0', apiVersion: '2024-10' }; // Validation function function validateConfig() { const errors = []; if (!CONFIG.storeDomain || CONFIG.storeDomain.includes('your-store')) { errors.push('Store domain must be configured'); } if (!CONFIG.storefrontAccessToken || CONFIG.storefrontAccessToken.includes('your-token')) { errors.push('Storefront access token must be configured'); } if (!CONFIG.bundleProductId || CONFIG.bundleProductId === '8138138353916') { errors.push('Bundle product ID must be configured'); } if (errors.length > 0) { console.error('Configuration errors:', errors); throw new Error('Widget configuration incomplete: ' + errors.join(', ')); } } ``` #### 4. Environment-based Configuration For production applications, use environment variables: ```javascript // Environment-based configuration const CONFIG = { storeDomain: process.env.SHOPIFY_STORE_DOMAIN || 'https://your-store.myshopify.com', storeIdentifier: process.env.SHOPIFY_STORE_DOMAIN?.replace('https://', '') || 'your-store.myshopify.com', storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_TOKEN || 'shpat_your-token-here', bundleProductId: process.env.BUNDLE_PRODUCT_ID || '8138138353916', appName: 'Bundle Widget', appVersion: '1.0.0' }; ``` ### Using GraphQL with recharge.cdn.getCDNBundleSettings() This approach provides maximum flexibility by separating bundle configuration from product data loading: #### 1. Method Overview ```javascript // Get bundle configuration from Recharge const bundleSettings = await recharge.cdn.getCDNBundleSettings(externalProductId); // Use GraphQL to fetch product data from collections const collectionData = await shopifyClient.request(COLLECTION_QUERY, { variables: { id: `gid://shopify/Collection/${collectionId}` } }); ``` #### 5. Implementation Pattern ```javascript async function initializeBundleWidget() { try { // Validate configuration before starting validateConfig(); // Initialize Recharge SDK await recharge.init({ storeIdentifier: CONFIG.storeIdentifier, appName: CONFIG.appName, appVersion: CONFIG.appVersion }); // Initialize Shopify Storefront client const shopifyClient = ShopifyStorefrontAPIClient.createStorefrontApiClient({ storeDomain: CONFIG.storeDomain, apiVersion: CONFIG.apiVersion, publicAccessToken: CONFIG.storefrontAccessToken }); // Get bundle settings from Recharge const bundleSettings = await recharge.cdn.getCDNBundleSettings(CONFIG.bundleProductId); // Process bundle variants and extract collection IDs const variantCollections = getVariantCollections(bundleSettings); // Load products from collections using GraphQL for (const variant of variantCollections) { for (const collection of variant.collections) { const collectionData = await fetchCollectionProducts(collection.collectionId, shopifyClient); // Process collection data... } } } catch (error) { console.error('Error loading bundle data:', error); // Show user-friendly error message showConfigurationError(error.message); } } // Helper function to show configuration errors function showConfigurationError(message) { const errorContainer = document.getElementById('bundle-error') || document.body; errorContainer.innerHTML = `

Configuration Error

${message}

Please check your widget configuration and try again.

`; } ``` #### 6. Bundle Settings Structure The `getCDNBundleSettings()` method returns bundle configuration with this structure: ```javascript // Example bundle settings structure { "external_product_id": "8138138353916", "variants": [ { "id": 32967, "external_variant_id": "44636069724412", "enabled": true, "items_count": 2, "option_sources": [ { "id": 403608, "collection_id": "400595910908", "quantity_min": null, "quantity_max": null } ], "ranges": [ { "id": 1298, "quantity_min": 1, "quantity_max": null } ], "selection_defaults": [] } ] } ``` #### 7. GraphQL Collection Query Use this enhanced GraphQL query to fetch collection products with pagination support: ```graphql query getCollectionWithMetafields($id: ID!, $first: Int!, $after: String) { collection(id: $id) { id title description products(first: $first, after: $after) { edges { node { id title description images(first: 5) { edges { node { url altText } } } variants(first: 100) { edges { node { id price { amount currencyCode } availableForSale } } } # Add metafields for enhanced product data dietaryPreferences: metafield(namespace: "shopify", key: "dietary-preferences") { id type references(first: 10) { nodes { ... on Metaobject { id fields { key value } } } } } } } pageInfo { hasNextPage endCursor } } } } ``` #### 8. Pagination Handling For large collections, implement pagination to load all products: ```javascript async function fetchAllProductsFromCollection(collectionId, shopifyClient) { const collectionGid = `gid://shopify/Collection/${collectionId}`; let allProducts = []; let hasNextPage = true; let cursor = null; while (hasNextPage) { const response = await shopifyClient.request(COLLECTION_QUERY, { variables: { id: collectionGid, first: 250, // Maximum allowed per request after: cursor } }); const collection = response.data?.collection; if (!collection) break; // Add products from this page const products = collection.products.edges || []; allProducts = allProducts.concat(products); // Check pagination const pageInfo = collection.products.pageInfo; hasNextPage = pageInfo.hasNextPage; cursor = pageInfo.endCursor; } return { ...collection, products: { edges: allProducts } }; } ``` #### 9. Bundle Rules Processing Extract and process bundle rules from settings: ```javascript function getVariantCollections(bundleSettings) { if (!bundleSettings || !bundleSettings.variants) { return []; } return bundleSettings.variants.map(variant => ({ variantId: variant.id, externalVariantId: variant.external_variant_id, enabled: variant.enabled, itemsCount: variant.items_count, collections: variant.option_sources.map(source => ({ optionSourceId: source.id, collectionId: source.collection_id, quantityMin: source.quantity_min, quantityMax: source.quantity_max })), ranges: variant.ranges, defaults: variant.selection_defaults })); } ``` #### 10. Validation Implementation Implement bundle validation using the extracted rules: ```javascript function validateBundleSelections(selections, variant) { const errors = []; const totalItems = selections.reduce((sum, item) => sum + item.quantity, 0); // Check bundle size constraints if (variant.ranges && variant.ranges.length > 0) { const range = variant.ranges[0]; const minRequired = range.quantity_min || 0; const maxAllowed = range.quantity_max || Infinity; if (totalItems < minRequired) { errors.push(`Bundle must contain at least ${minRequired} items total`); } if (totalItems > maxAllowed) { errors.push(`Bundle cannot exceed ${maxAllowed} items total`); } } // Check collection constraints variant.collections.forEach((collection, index) => { const collectionItems = selections.filter(item => item.collectionId === collection.collectionId ); const totalFromCollection = collectionItems.reduce((sum, item) => sum + item.quantity, 0); const minRequired = collection.quantityMin || 0; const maxAllowed = collection.quantityMax || Infinity; if (minRequired > 0 && totalFromCollection < minRequired) { errors.push(`Collection ${index + 1} requires at least ${minRequired} items`); } if (maxAllowed < Infinity && totalFromCollection > maxAllowed) { errors.push(`Collection ${index + 1} cannot exceed ${maxAllowed} items`); } }); return errors; } ``` ### Benefits of GraphQL Approach 1. **Maximum Flexibility**: Full control over data queries and processing 2. **Custom Metafields**: Access to product metafields for rich data 3. **Caching Control**: Implement custom caching strategies 4. **Filtering Options**: Add custom product filtering and search 5. **Third-party Integration**: Easy integration with external systems 6. **Mobile Optimization**: Optimize queries for mobile performance 7. **Advanced Features**: Support for complex product relationships ### Key Features 1. **Headless Optimized**: Perfect for custom storefronts and mobile apps 2. **Flexible Data Access**: Query exactly the data you need 3. **Metafield Support**: Access rich product metadata 4. **Custom Caching**: Implement optimized caching strategies 5. **Advanced Pagination**: Handle large product collections efficiently 6. **Third-party Ready**: Easy integration with external systems This approach provides maximum flexibility for custom implementations while maintaining full compatibility with Recharge bundle functionality. ## ⚑ REQUIRED: Extracting Collections from Bundle Settings ### Get Collections for Each Variant Bundle variants contain `option_sources` which represent Shopify collections that contain the available products for that variant. ```javascript /** * Extracts collections associated with each bundle variant * @param {Object} bundleSettings - The bundle settings from getCDNBundleSettings * @returns {Array} Array of variant objects with their associated collections */ function getVariantCollections(bundleSettings) { if (!bundleSettings || !bundleSettings.variants) { return []; } return bundleSettings.variants.map(variant => ({ variantId: variant.id, externalVariantId: variant.external_variant_id, enabled: variant.enabled, itemsCount: variant.items_count, collections: variant.option_sources.map(source => ({ optionSourceId: source.id, collectionId: source.collection_id, quantityMin: source.quantity_min, quantityMax: source.quantity_max })), ranges: variant.ranges, defaults: variant.selection_defaults })); } /** * Get all unique collection IDs from bundle settings * @param {Object} bundleSettings - The bundle settings from getCDNBundleSettings * @returns {Array} Array of unique collection IDs */ function getAllCollectionIds(bundleSettings) { const variantCollections = getVariantCollections(bundleSettings); const allCollectionIds = variantCollections.flatMap(variant => variant.collections.map(collection => collection.collectionId) ); return [...new Set(allCollectionIds)]; } /** * Get collections for a specific variant * @param {Object} bundleSettings - The bundle settings from getCDNBundleSettings * @param {string} externalVariantId - The Shopify variant ID * @returns {Array} Array of collection objects for the variant */ function getCollectionsForVariant(bundleSettings, externalVariantId) { const variantCollections = getVariantCollections(bundleSettings); const variant = variantCollections.find(v => v.externalVariantId === externalVariantId); return variant ? variant.collections : []; } ``` ### Usage Example ```javascript // After loading bundle settings const bundleSettings = await recharge.cdn.getCDNBundleSettings('8138138353916'); // Get all variant collections const variantCollections = getVariantCollections(bundleSettings); console.log('Variant Collections:', variantCollections); // Get all unique collection IDs const collectionIds = getAllCollectionIds(bundleSettings); console.log('Collection IDs:', collectionIds); // Get collections for specific variant const collections = getCollectionsForVariant(bundleSettings, '44636069724412'); console.log('Collections for variant:', collections); ``` ### Bundle Rules Explanation **Important:** Understanding bundle sizing and collection constraints: - **`variant.ranges`** - Defines the total bundle size for dynamic pricing: - `quantity_min: null` = Optional (0 minimum, use 0 as fallback) - `quantity_min: 1` = Must include at least 1 item in bundle - `quantity_max: null` = Infinite maximum (no upper limit) - `quantity_max: 20` = Maximum 20 items allowed in bundle - **`items_count`** - Should be ignored in favor of ranges for dynamic bundles - **`option_sources` quantity constraints** - Per-collection rules: - `quantity_min: null` = Collection is optional (no minimum required) - `quantity_min: 3` = Must include at least 3 items from this collection - `quantity_max: 5` = Can include maximum 5 items from this collection - `quantity_max: null` = No maximum limit for this collection ### Example Output ```javascript // variantCollections result: [ { variantId: 32967, externalVariantId: "44636069724412", enabled: true, itemsCount: 2, // IGNORE: Use ranges instead collections: [ { optionSourceId: 403608, collectionId: "400595910908", // Breakfast collection quantityMin: null, // Optional collection (no minimum) quantityMax: null // No maximum limit }, { optionSourceId: 403609, collectionId: "400321020156", // Lunch collection quantityMin: 3, // Must include at least 3 lunch items quantityMax: 5 // Can include max 5 lunch items } ], ranges: [{ id: 1298, quantity_min: 1, quantity_max: null }], // Bundle size: 1-∞ items total (no max limit) defaults: [...] } ] ``` ## ⚑ REQUIRED: How Bundle Selection Works ### Selection Rules Implementation When building a bundle widget, you need to track selections and validate against both bundle-level and collection-level constraints: #### 1. Bundle-Level Constraints (ranges) ```javascript // Example: ranges = [{ quantity_min: 1, quantity_max: null }] // Means: Bundle must contain 1-∞ items total function validateBundleSize(selections, ranges) { const totalItems = selections.reduce((sum, item) => sum + item.quantity, 0); const range = ranges[0]; // Usually one range per variant const minRequired = range.quantity_min || 0; const maxAllowed = range.quantity_max || Infinity; return totalItems >= minRequired && totalItems <= maxAllowed; } ``` #### 2. Collection-Level Constraints (option_sources) ```javascript // Example: { quantityMin: 3, quantityMax: 5, collectionId: "123" } // Means: Must have 3-5 items from this specific collection function validateCollectionConstraints(selections, collections) { return collections.every(collection => { const collectionItems = selections.filter(item => item.collectionId === collection.collectionId ); const totalFromCollection = collectionItems.reduce((sum, item) => sum + item.quantity, 0); const minRequired = collection.quantityMin || 0; const maxAllowed = collection.quantityMax || Infinity; return totalFromCollection >= minRequired && totalFromCollection <= maxAllowed; }); } ``` #### 3. Selection Data Structure ```javascript // Keep selections in this format: const bundleSelections = { variantId: "44636069724412", items: [ { productId: "gid://shopify/Product/123", variantId: "gid://shopify/ProductVariant/456", quantity: 2, collectionId: "400595910908", title: "Product Name", price: "10.00" } ], isValid: true, // Check against all constraints totalItems: 2, errors: [] // Validation error messages }; ``` ## πŸ”§ REFERENCE: Complete Bundle Widget Implementation ### HTML Structure Build your bundle widget with these components: ```html Bundle Widget

Bundle Builder

Loading bundle...
``` ### Theme-Aligned Styling (Online Store β€” Use Theme Settings) When building storefront widgets, align with the store’s theme rather than hardcoding styles: - Reference theme schema settings via Liquid `settings` (from `config/settings_schema.json` / `config/settings_data.json`). - Prefer theme CSS variables and existing utility/component classes over bespoke CSS. - Expose tokens and class hooks to JS via inline CSS variables or data attributes rendered by Liquid. Example (Liquid + HTML) injecting theme values and class hooks: ```liquid
``` Example (JS) reading theme-driven tokens/classes without hardcoding styles: ```js 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 ``` ### CSS Styling (Headless/Custom) Reference styles for a professional-looking bundle widget (customize as needed): ```css .container { max-width: 1200px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif; } .info { background-color: #e7f3ff; border: 1px solid #b6d7ff; border-radius: 4px; padding: 12px; margin: 10px 0; } /* Collection Layout */ .collection-section { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 8px; background: #fafafa; } /* Product Grid */ .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-top: 15px; } .product-card { background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; text-align: center; transition: box-shadow 0.2s; } .product-card:hover { box-shadow: 0 4px 8px rgba(0,0,0,0.1); } .product-image { width: 100%; height: 150px; object-fit: cover; border-radius: 4px; margin-bottom: 10px; } .product-title { font-weight: bold; margin-bottom: 5px; font-size: 14px; } .product-price { color: #007bff; font-weight: bold; } /* Quantity Controls */ .quantity-controls { display: flex; align-items: center; justify-content: center; margin-top: 10px; gap: 10px; } .quantity-btn { background: #007bff; color: white; border: none; border-radius: 4px; width: 30px; height: 30px; cursor: pointer; font-size: 16px; } .quantity-btn:hover { background: #0056b3; } .quantity-btn:disabled { background: #ccc; cursor: not-allowed; } .quantity-display { font-weight: bold; min-width: 30px; text-align: center; } /* Bundle Summary */ .bundle-summary { background: #e8f4fd; border: 2px solid #007bff; border-radius: 8px; padding: 15px; margin: 20px 0; } /* Validation Messages */ .validation-error { color: #dc3545; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; padding: 8px; margin: 5px 0; } .validation-success { color: #155724; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; padding: 8px; margin: 5px 0; } ``` ### 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 ## 🎨 OPTIONAL: Working with Shopify Metafields in Bundle Widgets > **🚨 IMPORTANT:** Metafields are completely OPTIONAL. You can build a fully functional bundle widget without any metafields. This section shows how to enhance your widget IF you already have custom product data stored in Shopify metafields. ### When to Use This Section **βœ… Use metafields IF:** - You already have custom product attributes in Shopify metafields - You want to display additional product information beyond title/price/image - Your products have special characteristics that help customers make choices (materials, ingredients, certifications, etc.) **❌ Skip this section IF:** - You don't have metafields set up in your store - You want to build a basic bundle widget first - You're happy with just showing product title, price, and image ### Example Use Cases (Choose What Applies to Your Store) **These are just examples - use what matches your store type:** **IF you're a meal prep company:** Display dietary preferences (Keto, Vegan) and allergen warnings **IF you're a clothing store:** Show materials (Cotton, Polyester), care instructions, or style tags **IF you're a supplement company:** Display ingredients, certifications, or health benefits **IF you're a beauty brand:** Show skin type compatibility, ingredient highlights, or product benefits **IF you sell any products with custom attributes:** This section shows how to display them in your bundle widget > **πŸ’‘ Remember:** These are just examples! The meal prep scenario below is ONE possible use case. Replace the metafield names, types, and styling with whatever matches your actual store's data. ### Setting Up Metafields #### 1. Example Metafield Structure (Meal Prep Use Case) > **πŸ“ Note:** This is just ONE example for a meal prep company. Replace these metafield types with whatever matches your actual store's needs. If you were a meal prep company, you might create these metafields in your Shopify Admin: **Dietary Preferences** (`shopify.dietary-preferences`) - Type: `list.metaobject_reference` - Metaobject Definition: `dietary_preference` - Fields: `label` (text), `description` (text) **Allergen Information** (`shopify.allergen-information`) - Type: `list.metaobject_reference` - Metaobject Definition: `allergen_info` - Fields: `label` (text), `severity` (text) **Product Badges** (`custom.badge`) - Type: `list.metaobject_reference` - Metaobject Definition: `product_badge` - Fields: `label` (text), `background` (color), `text_color` (color) #### 2. Example GraphQL Query with Metafields > **πŸ“ Note:** This example query includes the meal prep metafields shown above. Modify the metafield queries to match YOUR store's actual metafield structure. Here's how you would update your Shopify Storefront API query to include metafields (using the meal prep example). Since collections can contain more than 250 products, you need to implement pagination to load all products: ```graphql query getCollectionWithMetafields($id: ID!, $first: Int!, $after: String) { collection(id: $id) { id title description products(first: $first, after: $after) { edges { node { id title description # Dietary preferences metafield dietaryPreferences: metafield(namespace: "shopify", key: "dietary-preferences") { id type references(first: 10) { nodes { ... on Metaobject { id fields { key value } } } } } # Allergen information metafield allergenInformation: metafield(namespace: "shopify", key: "allergen-information") { id type references(first: 10) { nodes { ... on Metaobject { id fields { key value } } } } } # Product badges metafield badges: metafield(namespace: "custom", key: "badge") { id type references(first: 10) { nodes { ... on Metaobject { id fields { key value } } } } } # Standard product fields images(first: 5) { edges { node { url altText } } } variants(first: 100) { edges { node { id price { amount currencyCode } availableForSale } } } } } pageInfo { hasNextPage endCursor } } } } ``` ### Loading All Products with Pagination Since Shopify limits each GraphQL request to a maximum of 250 resources, you need to implement pagination to load all products from large collections. Here's how to handle pagination: ```javascript /** * Fetch all products from a collection using pagination * @param {string} collectionId - Shopify collection ID * @returns {Promise} Collection data with all products */ async function fetchAllProductsFromCollection(collectionId) { const collectionGid = `gid://shopify/Collection/${collectionId}`; let allProducts = []; let hasNextPage = true; let cursor = null; let pageCount = 0; const startTime = Date.now(); console.log(`πŸ”„ Loading collection ${collectionId}...`); while (hasNextPage) { pageCount++; console.log(`πŸ“„ Loading page ${pageCount} for collection ${collectionId}`); 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) { console.warn(`⚠️ Collection ${collectionId} not found`); return null; } // 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; console.log(`βœ… Page ${pageCount} loaded: ${products.length} products (total: ${allProducts.length})`); // Store collection metadata from first page if (pageCount === 1) { var collectionMeta = { id: collection.id, title: collection.title, handle: collection.handle, description: collection.description }; } } const loadTime = Date.now() - startTime; console.log(`🎯 Collection ${collectionId} loaded completely:`, { title: collectionMeta.title, totalProducts: allProducts.length, totalPages: pageCount, loadTimeMs: loadTime }); // Return collection with all products return { ...collectionMeta, products: { edges: allProducts } }; } ``` ### Pagination Performance Considerations According to [Shopify's documentation](https://shopify.dev/docs/api/usage/pagination-graphql#search-performance-considerations), when paginating large collections: 1. **Maximum per request**: 250 resources is the maximum allowed per GraphQL request 2. **Performance optimization**: For better performance, avoid using search filters with different sort keys 3. **Cursor-based pagination**: Use `hasNextPage`, `endCursor`, and `after` for efficient pagination 4. **Memory management**: For very large collections (1000+ products), consider implementing lazy loading or virtual scrolling ### Real-world Example Output When loading a large collection, you'll see console output like this: ``` πŸ”„ Loading collection 400595910908... πŸ“„ Loading page 1 for collection 400595910908 βœ… Page 1 loaded: 250 products (total: 250) πŸ“„ Loading page 2 for collection 400595910908 (cursor: eyJsYXN0X2lkIjo3...) βœ… Page 2 loaded: 250 products (total: 500) πŸ“„ Loading page 3 for collection 400595910908 (cursor: eyJsYXN0X2lkIjo4...) βœ… Page 3 loaded: 180 products (total: 680) 🎯 Collection 400595910908 loaded completely: { title: "Breakfast Collection", totalProducts: 680, totalPages: 3, loadTimeMs: 1247 } ``` ### JavaScript Implementation #### 1. Metafield Extraction Functions ```javascript /** * Extract values from metaobject reference metafields */ function extractMetafieldValues(metafield) { if (!metafield || metafield.type !== 'list.metaobject_reference') { return []; } const values = []; if (metafield.references && metafield.references.nodes) { metafield.references.nodes.forEach(node => { if (node.fields) { const labelField = node.fields.find(f => f.key === 'label'); if (labelField) { values.push(labelField.value); } } }); } return values.filter(Boolean); } /** * Extract badge data with styling information */ function extractBadgeData(metafield) { if (!metafield || metafield.type !== 'list.metaobject_reference') { return []; } const badges = []; if (metafield.references && metafield.references.nodes) { metafield.references.nodes.forEach(node => { if (node.fields) { const badge = {}; node.fields.forEach(field => { if (field.key === 'label') { badge.label = field.value; } else if (field.key === 'background') { badge.background = field.value; } else if (field.key === 'text_color') { badge.textColor = field.value; } }); if (badge.label && badge.background && badge.textColor) { badges.push(badge); } } }); } return badges; } /** * Create product pills for custom metafield display * EXAMPLE: This shows meal prep use case - adapt for your store's metafields */ function createProductPills(product) { const pills = []; // EXAMPLE: Extract dietary preferences (replace with YOUR metafield) if (product.dietaryPreferences) { const dietaryValues = extractMetafieldValues(product.dietaryPreferences); dietaryValues.forEach(value => { pills.push({ text: value, type: 'dietary', cssClass: value.toLowerCase().replace(/[^a-z0-9]/g, '-') }); }); } // EXAMPLE: Extract allergen information (replace with YOUR metafield) if (product.allergenInformation) { const allergenValues = extractMetafieldValues(product.allergenInformation); allergenValues.forEach(value => { pills.push({ text: value, type: 'allergen', cssClass: value.toLowerCase().replace(/[^a-z0-9]/g, '-') }); }); } return pills; } ``` #### 2. Rendering Functions ```javascript /** * Render product badges for image overlay */ function renderProductBadges(badges) { if (!badges || badges.length === 0) { return '
'; } const badgesHTML = badges.map(badge => { const cls = ['product-badge', badge.cssClass].filter(Boolean).join(' '); return `${badge.label}`; }).join(''); return `
${badgesHTML}
`; } /** * Render product pills for dietary/allergen info */ function renderProductPills(pills) { if (!pills || pills.length === 0) { return '
'; } const pillsHTML = pills.map(pill => `${pill.text}` ).join(''); return `
${pillsHTML}
`; } ``` #### 3. Product Card Integration ```javascript // In your product rendering function const pills = createProductPills(product); const badges = extractBadgeData(product.badges); const pillsHTML = renderProductPills(pills); const badgesHTML = renderProductBadges(badges); const productHTML = `
${product.title} ${badgesHTML}

${product.title}

${pillsHTML}

${product.description}

$${product.variants.edges[0]?.node?.price.amount}
`; ``` ### Theme-Aligned Metafield Styling (Online Store) Prefer the theme’s existing badge/pill styles and tokens rather than bespoke CSS: - Reuse theme badge/pill classes, or map your pills to theme component classes. - If tokens are needed in JS, inject them as CSS variables via Liquid using `settings`. Example (Liquid) exposing theme tokens for JS/CSS without hardcoding: ```liquid
{%- comment -%} Render badges from JS/HTML with classes the theme understands {%- endcomment -%}
``` ### CSS Styling for Metafield Content (Headless/Custom) Use theme classes/tokens instead of bespoke CSS. Map your pills/badges to existing theme components or expose needed tokens via Liquid `settings` and apply theme utility classes. Avoid hardcoded colors or styles. ### Product Detail Modal Integration For enhanced user experience, create a modal that shows detailed metafield information: ```javascript function openProductModal(product) { // Set dietary preferences const dietarySection = document.getElementById('modal-dietary-section'); const dietaryItems = document.getElementById('modal-dietary-items'); if (product.dietaryPreferences) { const dietaryValues = extractMetafieldValues(product.dietaryPreferences); if (dietaryValues.length > 0) { dietaryItems.innerHTML = dietaryValues.map(value => `${value}` ).join(''); dietarySection.style.display = 'block'; } } // Set allergen information const allergenSection = document.getElementById('modal-allergen-section'); const allergenItems = document.getElementById('modal-allergen-items'); if (product.allergenInformation) { const allergenValues = extractMetafieldValues(product.allergenInformation); if (allergenValues.length > 0) { allergenItems.innerHTML = allergenValues.map(value => `${value}` ).join(''); allergenSection.style.display = 'block'; } } // Show modal modal.style.display = 'flex'; } ``` ### Benefits of Using Metafields in Bundle Widgets 1. **Enhanced Product Discovery**: Customers can quickly identify suitable products based on custom attributes 2. **Trust Building**: Clear product information builds customer confidence 3. **Visual Appeal**: Custom badges highlight featured items and promotions 4. **Improved Filtering**: Real-time filtering by custom attributes improves UX 5. **Detailed Information**: Modal dialogs provide comprehensive product details 6. **Brand Consistency**: Custom styling maintains brand identity across the shopping experience This metafield integration transforms basic product listings into rich, informative experiences that help customers make informed decisions while building their custom bundles. **Examples of what different store types might display:** - **Meal prep companies**: Dietary preferences, allergen warnings, nutritional info - **Clothing stores**: Materials, care instructions, sizing details - **Beauty brands**: Skin type compatibility, ingredient highlights - **Home goods**: Materials, care instructions, style categories ## 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. **❌ Don't manually construct cart payloads** **βœ… Use Recharge SDK to get properly formatted cart items** ### 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 ```graphql 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 */ function convertRechargeItemsToGraphQL(rechargeCartItems) { return rechargeCartItems.map(item => ({ merchandiseId: `gid://shopify/ProductVariant/${item.id}`, // ID already a string quantity: item.quantity, attributes: Object.entries(item.properties || {}).map(([key, value]) => ({ key, value: String(value) })) })); } ``` #### 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 const cartLines = convertRechargeItemsToGraphQL(rechargeCartItems); 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); 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; } } ``` ### Unified Cart Handler To support both implementations, create a unified handler that detects the environment: ```javascript /** * Detect if Shopify routes object is available (online store) */ function isShopifyOnlineStore() { return typeof window !== 'undefined' && window.Shopify && window.Shopify.routes; } /** * Universal bundle cart handler */ async function addBundleToCart(bundleSelections) { if (!bundleSelections.isValid) { alert('Please complete your bundle selection before adding to cart.'); return; } // Disable add to cart button during processing const addToCartBtn = document.getElementById('addToCartBtn'); const originalText = addToCartBtn.textContent; addToCartBtn.disabled = true; addToCartBtn.textContent = 'Adding to Cart...'; try { if (isShopifyOnlineStore()) { // Use Ajax Cart API for online store await addBundleToCartAjax(bundleSelections); } else { // Use Storefront GraphQL API for headless/custom implementations await addBundleToCartGraphQL(bundleSelections); } } catch (error) { // Re-enable button on error addToCartBtn.disabled = false; addToCartBtn.textContent = originalText; } } // Event listener for add to cart button document.getElementById('addToCartBtn').addEventListener('click', () => { addBundleToCart(bundleSelections); }); ``` ### Complete Updated Bundle Summary Function Update your bundle summary function to integrate the cart functionality: ```javascript 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'; } ``` ### 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. **Error Handling**: Implement proper error handling for both cart APIs 5. **Loading States**: Show loading indicators during cart operations 6. **Selling Plans**: Exclude `sellingPlan` parameter for now as mentioned ### Testing Your Implementation Test the cart functionality with actual bundle selections from your widget: ```javascript // Example of testing with real bundle data // (This would come from actual user selections in your widget) console.log('Current bundle selections:', bundleSelections); // Test the cart functionality // This will use the Recharge SDK to get proper cart items if (bundleSelections.isValid) { addBundleToCart(bundleSelections); } else { console.log('Bundle not valid:', bundleSelections.errors); } ``` ### Recharge SDK Output Structure The `recharge.bundle.getDynamicBundleItems()` function returns cart items with this actual 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 This implementation provides a robust solution for adding Recharge dynamic bundles to cart in both Shopify online store and headless/custom implementations using the official Recharge SDK. ## ⚑ REQUIRED: Fixed Price Bundle Implementation 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 { // 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 --- ## πŸ”§ 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: `https://static.rechargecdn.com/assets/storefront/recharge-client-1.46.0.min.js` - [ ] 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 - [ ] No additional API tokens required **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()`) - [ ] Handle GraphQL query failures and timeouts - [ ] Cache product data appropriately ### πŸ›’ Cart Integration (CRITICAL) **Recharge Bundle Format:** - [ ] **ALWAYS use `recharge.bundle.getDynamicBundleItems(bundle, handle)`** - [ ] **NEVER manually construct cart payloads** - [ ] Convert selections to proper Recharge bundle format - [ ] Include all required bundle properties (`_rc_bundle`, `_rc_bundle_variant`, etc.) **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 - [ ] Handle cart API errors and provide user feedback **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 - [ ] Include product variants in `findMatchingSellingPlan()` function calls - [ ] Log matching method used ('direct_allocation' vs 'heuristic_match') - [ ] **Product Filtering**: Use `getBundleSellingPlanIds()` to get all bundle plan IDs for compatibility checking **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 **Cart Validation:** - [ ] Validate bundle before adding to cart - [ ] Show clear error messages for invalid bundles - [ ] Disable cart button for invalid selections - [ ] Test cart functionality with various bundle combinations ### 🎨 User Experience **Code Quality:** - [ ] Clean onclick handlers (no complex objects in HTML attributes) - [ ] Store product data in Map/object, use simple function calls in HTML - [ ] Separate concerns: data, logic, and presentation - [ ] Implement proper error boundaries **Real-time Validation:** - [ ] Validate bundle constraints as users make selections - [ ] Show validation errors immediately with clear messaging - [ ] Display success states when bundle is valid - [ ] Update validation state on every selection change **Loading & Error States:** - [ ] Show loading indicators during async operations - [ ] Handle network failures gracefully - [ ] Provide retry mechanisms for failed operations - [ ] Display appropriate error messages to users **Visual Feedback:** - [ ] Highlight selected items clearly - [ ] Show quantity controls with proper disable states - [ ] Update bundle summary in real-time - [ ] Visual indication of bundle completion status ### πŸ“± Responsive Design **Mobile Optimization:** - [ ] Test on mobile devices (iOS Safari, Android Chrome) - [ ] Ensure touch targets are appropriate size (44px minimum) - [ ] Optimize product grid for small screens - [ ] Test scrolling and modal interactions on mobile **Cross-browser Compatibility:** - [ ] Test in Chrome, Firefox, Safari, Edge - [ ] Verify ES6+ features have proper fallbacks - [ ] Test with JavaScript disabled (graceful degradation) - [ ] Validate HTML and CSS for standards compliance ### β™Ώ Accessibility **Keyboard Navigation:** - [ ] All interactive elements are keyboard accessible - [ ] Focus indicators are visible and clear - [ ] Logical tab order throughout the widget - [ ] Modal dialogs trap focus appropriately **Screen Reader Support:** - [ ] Proper ARIA labels for all controls - [ ] Semantic HTML structure (headings, lists, forms) - [ ] Alt text for all product images - [ ] Status announcements for dynamic content changes **Visual Accessibility:** - [ ] Sufficient color contrast ratios (WCAG AA: 4.5:1) - [ ] Information not conveyed by color alone - [ ] Readable font sizes (minimum 16px on mobile) - [ ] Zoom support up to 200% without horizontal scroll ### πŸ§ͺ Testing Requirements **Functional Testing:** - [ ] Test all bundle rule combinations (min/max constraints) - [ ] Verify cart integration with actual purchases - [ ] Test with empty collections and missing products - [ ] Validate error scenarios and edge cases **Performance Testing:** - [ ] Test with large collections (1000+ products) - [ ] Measure and optimize bundle loading times - [ ] Test pagination performance with slow connections - [ ] Verify memory usage doesn't grow over time **Integration Testing:** - [ ] Test with actual Shopify store and Recharge setup - [ ] Verify bundle appears correctly in cart and checkout - [ ] Test with real payment processing - [ ] Validate analytics tracking (if implemented) ### πŸ”’ Security & Data **Data Validation:** - [ ] Validate all user inputs before processing - [ ] Sanitize product data from external APIs - [ ] Handle malformed API responses safely - [ ] Validate bundle configuration before use **API Security:** - [ ] Use read-only API tokens where possible - [ ] Implement proper CORS handling - [ ] Rate limit API calls to prevent abuse - [ ] Log errors without exposing sensitive data ### πŸ“Š Monitoring & Analytics **Error Tracking:** - [ ] Implement error logging for production issues - [ ] Track bundle validation failures - [ ] Monitor API failure rates - [ ] Set up alerts for critical errors **Performance Monitoring:** - [ ] Track bundle loading times - [ ] Monitor cart conversion rates - [ ] Measure user engagement metrics - [ ] Track most popular bundle combinations **User Analytics:** - [ ] Track bundle builder interactions - [ ] Monitor drop-off points in bundle flow - [ ] Measure time to complete bundle selection - [ ] Track successful vs abandoned bundles ### πŸš€ Production Deployment **Configuration Management:** - [ ] Use environment variables for store configuration - [ ] Separate development and production API endpoints - [ ] Implement proper staging environment testing - [ ] Version control all configuration changes **Performance Optimization:** - [ ] Minify and compress JavaScript and CSS - [ ] Optimize images for web delivery - [ ] Implement caching strategies for product data - [ ] Use CDN for static assets **SEO & Meta Data:** - [ ] Proper page titles and descriptions - [ ] Structured data for bundle products - [ ] Open Graph tags for social sharing - [ ] Canonical URLs for bundle pages This comprehensive checklist ensures your bundle widget is production-ready, accessible, and provides an excellent user experience across all devices and platforms. ## πŸ”§ REFERENCE: Debugging Selling Plan Matching Issues ### Common Issues and Debugging Strategies When implementing dynamic bundle selling plan matching, several issues can arise. Here's a comprehensive debugging approach: #### 1. Comprehensive Debugging Functions Add extensive logging to track down selling plan extraction and matching issues: ```javascript // Enhanced debugging in getBundleSellingPlanRequirements() function getBundleSellingPlanRequirements() { console.log('πŸ” DEBUGGING: getBundleSellingPlanRequirements'); // Log the entire bundle data structure console.log('🚨 FULL BUNDLE DATA STRUCTURE:', JSON.stringify(bundleData, null, 2)); // Try multiple frequency extraction methods const requirements = allBundleSellingPlans.map((plan, index) => { let frequency = null; console.log(`🚨 FREQUENCY EXTRACTION DEBUG for plan ${plan.id}:`); // Method 1: Standard option name if (plan.options?.find(opt => opt.name === "Order Frequency and Unit")) { frequency = plan.options.find(opt => opt.name === "Order Frequency and Unit").value; } // Method 2: Alternative option names if (!frequency && plan.options?.find(opt => opt.name === "Delivery every")) { frequency = plan.options.find(opt => opt.name === "Delivery every").value; } // Method 3: Extract from plan name if (!frequency && plan.name) { const nameMatch = plan.name.match(/(\d+\s*-?\s*week)/i); if (nameMatch) { frequency = nameMatch[1].replace(/\s+/g, '-').toLowerCase(); } } console.log(`πŸ“ Bundle requirement extracted:`, { id: plan.id, name: plan.name, frequency, isValid: !!(frequency && plan.price_adjustments?.[0]) }); return { id: plan.id, frequency, priceAdjustment: plan.price_adjustments?.[0] }; }); // Critical: Check if no valid requirements found if (requirements.filter(req => req.frequency && req.priceAdjustment).length === 0) { console.log('🚨 NO VALID REQUIREMENTS! This will cause ALL products to be filtered out'); } return requirements.filter(req => req.frequency && req.priceAdjustment); } ``` #### 2. Product Compatibility Debugging Enhanced product filtering debugging: ```javascript function hasCompatibleSellingPlans(product, bundleRequirements) { console.log(`πŸ” DEBUGGING: hasCompatibleSellingPlans for ${product.title}`); // Log requirements being checked against console.log(`🚨 BUNDLE REQUIREMENTS TO MATCH:`, bundleRequirements); // Log the entire product selling plan structure console.log('🚨 FULL PRODUCT SELLING PLAN STRUCTURE:', JSON.stringify(product.selling_plan_groups, null, 2)); // Critical check: if no bundle requirements, explain why if (bundleRequirements.length === 0) { console.log(`🚨 CRITICAL: No bundle requirements found! getBundleSellingPlanRequirements() failed`); return false; } // Check each requirement vs each product plan const compatibilityResults = bundleRequirements.map(requirement => { const matchingPlan = productSellingPlans.find(plan => { // Use same frequency extraction logic as bundle const frequency = extractFrequency(plan); const priceAdjustment = plan.price_adjustments?.[0]; return frequency === requirement.frequency && priceAdjustment?.value_type === requirement.priceAdjustment?.value_type && priceAdjustment?.value === requirement.priceAdjustment?.value; }); if (!matchingPlan) { console.log(`❌ Missing plan for: ${requirement.frequency} ${requirement.priceAdjustment?.value}% discount`); console.log(` Available plans:`, productSellingPlans.map(p => ({ frequency: extractFrequency(p), discount: p.price_adjustments?.[0]?.value }))); } return !!matchingPlan; }); const isCompatible = compatibilityResults.every(result => result); console.log(`${isCompatible ? 'βœ…' : '❌'} Product compatibility: ${isCompatible ? 'COMPATIBLE' : 'INCOMPATIBLE'}`); return isCompatible; } ``` #### 3. Common Debugging Scenarios **Scenario 1: No Products Showing (All Filtered Out)** Expected Console Output: ``` 🚨 NO VALID REQUIREMENTS! This will cause ALL products to be filtered out 🚨 FREQUENCY EXTRACTION DEBUG for plan 4956258556: - Options array exists with 2 items Option 1: { name: "Recharge Plan ID", value: "19666641" } Option 2: { name: "Order Frequency and Unit", value: "1-week" } βœ… Found frequency via "Order Frequency and Unit": 1-week πŸ“ Bundle requirement extracted: { id: 4956258556, frequency: "1-week", isValid: true } ``` **Root Cause**: Frequency extraction working but no price adjustments found **Fix**: Check `plan.price_adjustments` structure **Scenario 2: Wrong Selling Plans Selected** Expected Console Output: ``` 🎯 User selected selling plan: { id: 4956258556, name: "1 week subscription with 20% discount" } πŸ”Ž Checking requirement 1: { frequency: "1-week", priceAdjustment: { value: 20 } } ❌ Product 7823765045500: Missing plan for requirement 1-week 20% discount Available plans: Plan 1: frequency="2-week", discount=20% Plan 2: frequency="1-week", discount=15% ``` **Root Cause**: Product has 1-week plan but wrong discount percentage **Fix**: Ensure product selling plans match bundle discount exactly #### 4. Expected Debug Output Patterns **Successful Frequency Extraction:** ``` 🚨 FREQUENCY EXTRACTION DEBUG for plan 4956258556: - Options array exists with 2 items βœ… Found frequency via "Order Frequency and Unit": 1-week πŸ“ Bundle requirement extracted: { id: 4956258556, frequency: "1-week", priceAdjustment: { value_type: "percentage", value: 20 }, isValid: true } ``` **Failed Frequency Extraction:** ``` 🚨 FREQUENCY EXTRACTION DEBUG for plan 4956258556: - Options array exists with 2 items ❌ No frequency option found - Trying to extract frequency from plan name: "1 week subscription with 20% discount" βœ… Extracted frequency from name: 1-week ``` **Product Compatibility Success:** ``` βœ… Product 7823764455676: Found matching plan (ID: 4864737532) for requirement 1-week 20% discount βœ… Product 7823764455676 (Udon noodles): 1/1 compatible plans - COMPATIBLE ``` **Product Compatibility Failure:** ``` ❌ Product 7823765045500: Missing plan for requirement 1-week 20% discount 🚨 COMPATIBILITY FAILURE ANALYSIS for product 7823765045500: Required: frequency="1-week", discount=20% Available plans: Plan 1: frequency="2-week", discount=20% Plan 2: frequency="1-week", discount=15% ❌ Product 7823765045500 (Dhal makhani): 0/1 compatible plans - INCOMPATIBLE ``` #### 5. Debugging Checklist When products aren't showing or wrong selling plans are selected: 1. **Check Bundle Requirements Extraction:** - Look for "🚨 NO VALID REQUIREMENTS!" message - Verify frequency extraction from options or plan name - Confirm price_adjustments exist and have correct structure 2. **Check Product Compatibility:** - Verify products have selling_plan_groups - Check if product selling plans use same frequency format as bundle - Ensure discount percentages match exactly 3. **Check Data Structure:** - Log full bundle data structure with JSON.stringify - Log full product selling plan structures - Compare actual vs expected option names 4. **Validate Frequency Formats:** - Bundle: "1-week" vs Product: "1 week" vs "weekly" - Normalize formats in extraction logic - Add fallback extraction from plan names #### 6. Quick Fixes for Common Issues **Issue**: `bundleFrequency: undefined` **Fix**: Add multiple extraction methods: ```javascript // Try multiple option name variations let frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value || plan.options?.find(opt => opt.name === "Delivery every")?.value || plan.options?.find(opt => opt.name.toLowerCase().includes('frequency'))?.value; // Fallback to plan name extraction if (!frequency && plan.name) { const match = plan.name.match(/(\d+\s*-?\s*week)/i); frequency = match ? match[1].replace(/\s+/g, '-').toLowerCase() : null; } ``` **Issue**: Products filtered out incorrectly **Fix**: Log actual vs expected requirements: ```javascript console.log('Expected:', bundleRequirements); console.log('Product plans:', productSellingPlans.map(p => ({ id: p.id, frequency: extractFrequency(p), discount: p.price_adjustments?.[0]?.value }))); ``` **Issue**: Selling plan matching failures **Fix**: Update to use enhanced two-step approach: ```javascript // Enhanced function call with all parameters const matchingSellingPlan = findMatchingSellingPlan( bundleSellingPlanData, childSellingPlans, childProductVariants // Add this parameter ); // Check debug output for direct vs heuristic matching // Look for "Direct selling_plan_allocation match" or "falling back to heuristic" ``` This comprehensive debugging approach will help identify and fix selling plan matching issues in dynamic bundles. ## πŸ”§ REFERENCE: Debugging Selling Plan Matching - Real Implementation Example ### Actual Issue Encountered **Problem**: Bundle widget showed "no products" because all products were being filtered out due to selling plan compatibility issues. **Root Cause**: The `getBundleSellingPlanRequirements()` function was failing to extract selling plan requirements properly, causing `hasCompatibleSellingPlans()` to filter out ALL products. ### Debugging Implementation That Fixed the Issue #### 1. Enhanced getBundleSellingPlanRequirements with Multiple Extraction Methods ```javascript function getBundleSellingPlanRequirements() { console.log('πŸ” DEBUGGING: getBundleSellingPlanRequirements'); if (!bundleData || !bundleData.selling_plan_groups) { console.log('❌ No bundle data or selling_plan_groups'); return []; } // 🚨 DEBUG: Log the entire bundle data structure to understand what we're working with console.log('🚨 FULL BUNDLE DATA STRUCTURE:', JSON.stringify(bundleData, null, 2)); const allBundleSellingPlans = []; bundleData.selling_plan_groups.forEach((group, groupIndex) => { console.log(`πŸ”Ž Bundle Group ${groupIndex + 1}:`, { name: group.name, hasSellingPlans: !!group.selling_plans, sellingPlansCount: group.selling_plans?.length || 0 }); if (group.selling_plans) { group.selling_plans.forEach((plan, planIndex) => { console.log(` πŸ“‹ Plan ${planIndex + 1}:`, { id: plan.id, name: plan.name, hasOptions: !!plan.options, optionsCount: plan.options?.length || 0, hasPriceAdjustments: !!plan.price_adjustments, priceAdjustmentsCount: plan.price_adjustments?.length || 0 }); // Log all available options to understand structure if (plan.options) { plan.options.forEach((option, optIndex) => { console.log(` 🎯 Option ${optIndex + 1}:`, { name: option.name, position: option.position, value: option.value }); }); } allBundleSellingPlans.push(plan); }); } }); // Extract the frequency and discount requirements using multiple methods const requirements = allBundleSellingPlans.map((plan, index) => { console.log(`🚨 FREQUENCY EXTRACTION DEBUG for plan ${plan.id}:`); let frequency = null; // Method 1: Standard option name const frequencyOption = plan.options?.find(opt => opt.name === "Order Frequency and Unit"); if (frequencyOption) { frequency = frequencyOption.value; console.log(` βœ… Found frequency via "Order Frequency and Unit": ${frequency}`); } // Method 2: Alternative option names if (!frequency) { const altOption = plan.options?.find(opt => opt.name === "Delivery every"); if (altOption) { frequency = altOption.value; console.log(` βœ… Found frequency via "Delivery every": ${frequency}`); } } // Method 3: Extract from plan name using regex if (!frequency && plan.name) { console.log(` πŸ” Trying to extract frequency from plan name: "${plan.name}"`); const nameMatch = plan.name.match(/(\d+\s*-?\s*week)/i); if (nameMatch) { frequency = nameMatch[1].replace(/\s+/g, '-').toLowerCase(); console.log(` βœ… Extracted frequency from name: ${frequency}`); } else { console.log(` ❌ No frequency pattern found in plan name`); } } // Get price adjustment data const priceAdjustment = plan.price_adjustments?.[0]; if (priceAdjustment) { console.log(` πŸ’° Price adjustment found:`, { valueType: priceAdjustment.value_type, value: priceAdjustment.value, orderCount: priceAdjustment.order_count }); } else { console.log(` ❌ No price adjustment found`); } console.log(`πŸ“ Bundle requirement extracted:`, { id: plan.id, name: plan.name, frequency, priceAdjustment: priceAdjustment ? { value_type: priceAdjustment.value_type, value: priceAdjustment.value } : null, isValid: !!(frequency && priceAdjustment) }); return { id: plan.id, name: plan.name, frequency, priceAdjustment: priceAdjustment ? { value_type: priceAdjustment.value_type, value: priceAdjustment.value } : null }; }).filter(req => req.frequency && req.priceAdjustment); // Only include complete requirements // 🚨 CRITICAL CHECK: Warn if no valid requirements found if (requirements.length === 0) { console.log('🚨 CRITICAL: NO VALID REQUIREMENTS! This will cause ALL products to be filtered out'); console.log('Check that:'); console.log('1. Bundle selling plans have "Order Frequency and Unit" options'); console.log('2. Bundle selling plans have price_adjustments array'); console.log('3. Plan name contains frequency pattern like "1 week" if options are missing'); } else { console.log(`βœ… Successfully extracted ${requirements.length} valid requirements`); } return requirements; } ``` #### 2. Enhanced Product Compatibility Checking ```javascript function hasCompatibleSellingPlans(product, bundleRequirements) { console.log(`πŸ” DEBUGGING: hasCompatibleSellingPlans for product ${product.id} (${product.title})`); // 🚨 DEBUG: Log bundle requirements being used for comparison console.log(`🚨 BUNDLE REQUIREMENTS TO MATCH:`, { requirementsCount: bundleRequirements.length, requirements: bundleRequirements.map(req => ({ frequency: req.frequency, discount: req.priceAdjustment ? `${req.priceAdjustment.value}% ${req.priceAdjustment.value_type}` : 'none' })) }); if (!product.selling_plan_groups || bundleRequirements.length === 0) { console.log(`❌ Product ${product.id} (${product.title}): No selling plans or no requirements`, { hasSellingPlanGroups: !!product.selling_plan_groups, requirementsLength: bundleRequirements.length }); return false; } console.log(`πŸ“Š Product ${product.id} structure check:`, { hasSellingPlanGroups: !!product.selling_plan_groups, sellingPlanGroupsCount: product.selling_plan_groups?.length || 0, requirementsToMatch: bundleRequirements.length }); // 🚨 DEBUG: Log the entire product selling plan structure console.log('🚨 FULL PRODUCT SELLING PLAN STRUCTURE:', JSON.stringify(product.selling_plan_groups, null, 2)); // Get all selling plans for this product const productSellingPlans = []; product.selling_plan_groups.forEach((group, groupIndex) => { console.log(`πŸ”Ž Product Group ${groupIndex + 1}:`, { name: group.name, hasSellingPlans: !!group.selling_plans, sellingPlansCount: group.selling_plans?.length || 0 }); if (group.selling_plans) { group.selling_plans.forEach((plan, planIndex) => { console.log(` πŸ“‹ Product Plan ${planIndex + 1}:`, { id: plan.id, name: plan.name, hasOptions: !!plan.options, optionsCount: plan.options?.length || 0 }); // Extract frequency using same logic as bundle let frequency = null; if (plan.options) { const frequencyOption = plan.options.find(opt => opt.name === "Order Frequency and Unit"); if (frequencyOption) { frequency = frequencyOption.value; } } console.log(` πŸ“ˆ Product plan details:`, { frequency, priceAdjustment: plan.price_adjustments?.[0] }); productSellingPlans.push(plan); }); } }); // Check if product has ALL required selling plans const compatibilityResults = bundleRequirements.map((requirement, reqIndex) => { console.log(`πŸ”Ž Checking requirement ${reqIndex + 1}: ${requirement.frequency} ${requirement.priceAdjustment?.value}% discount`); const matchingPlan = productSellingPlans.find(plan => { const frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; const priceAdjustment = plan.price_adjustments?.[0]; const frequencyMatch = frequency === requirement.frequency; const priceMatch = priceAdjustment?.value_type === requirement.priceAdjustment?.value_type && priceAdjustment?.value === requirement.priceAdjustment?.value; return frequencyMatch && priceMatch; }); if (matchingPlan) { console.log(`βœ… Product ${product.id}: Found matching plan (ID: ${matchingPlan.id}) for requirement ${requirement.frequency} ${requirement.priceAdjustment?.value}% discount`); } else { console.log(`❌ Product ${product.id}: Missing plan for requirement ${requirement.frequency} ${requirement.priceAdjustment?.value}% discount`); console.log(` Available plans:`, productSellingPlans.map(p => ({ id: p.id, frequency: p.options?.find(opt => opt.name === "Order Frequency and Unit")?.value, discount: p.price_adjustments?.[0]?.value }))); } return !!matchingPlan; }); const compatibleCount = compatibilityResults.filter(result => result).length; const isCompatible = compatibleCount === bundleRequirements.length; console.log(`${isCompatible ? 'βœ…' : '❌'} Product ${product.id} (${product.title}): ${compatibleCount}/${bundleRequirements.length} compatible plans - ${isCompatible ? 'COMPATIBLE' : 'INCOMPATIBLE'}`); return isCompatible; } ``` #### 3. Cart Debug Mode Implementation For debugging cart issues, temporarily add this debug mode to the `addBundleToCart` function: ```javascript // 🚨 DEBUG MODE: Log bundle data and stop before cart submission console.log('🚨 DEBUG MODE: Stopping before cart submission to debug selling plans'); console.log('πŸ“¦ Final bundle object being sent to Recharge SDK:', JSON.stringify(bundle, null, 2)); // Validate all selling plans are for the correct frequency const expectedFrequency = bundleSelections.selectedSellingPlan?.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; console.log(`🎯 Expected frequency for all items: "${expectedFrequency}"`); bundle.selections.forEach((selection, index) => { console.log(`Item ${index + 1} (Product ${selection.externalProductId}):`, { sellingPlanId: selection.sellingPlan, productId: selection.externalProductId, collectionId: selection.collectionId, quantity: selection.quantity }); }); // Get cart items from Recharge SDK but don't add to cart yet const rechargeCartItems = await recharge.bundle.getDynamicBundleItems(bundle, bundleProductData.handle); console.log('πŸ”§ Recharge cart items to be added:', rechargeCartItems); // πŸ›‘ STOP HERE - Don't actually add to cart in debug mode console.log('πŸ›‘ STOPPING: Debug mode active - cart submission prevented'); console.log('Remove debug code and restore cart submission after fixing selling plan matching'); return; ``` ### Expected Debug Output Patterns #### Successful Implementation: ``` πŸ” DEBUGGING: getBundleSellingPlanRequirements 🚨 FREQUENCY EXTRACTION DEBUG for plan 4956258556: βœ… Found frequency via "Order Frequency and Unit": 1-week πŸ“ Bundle requirement extracted: { id: 4956258556, frequency: "1-week", priceAdjustment: { value_type: "percentage", value: 20 }, isValid: true } βœ… Successfully extracted 1 valid requirements πŸ” DEBUGGING: hasCompatibleSellingPlans for product 7823764455676 (Udon noodles) πŸ”Ž Checking requirement 1: 1-week 20% discount βœ… Product 7823764455676: Found matching plan (ID: 4864737532) for requirement 1-week 20% discount βœ… Product 7823764455676 (Udon noodles): 1/1 compatible plans - COMPATIBLE ``` #### Failed Implementation: ``` 🚨 CRITICAL: NO VALID REQUIREMENTS! This will cause ALL products to be filtered out Check that: 1. Bundle selling plans have "Order Frequency and Unit" options 2. Bundle selling plans have price_adjustments array 3. Plan name contains frequency pattern like "1 week" if options are missing ❌ Product 7823765045500: Missing plan for requirement 1-week 20% discount Available plans: [ { id: 4864737601, frequency: "2-week", discount: 20 }, { id: 4864737602, frequency: "1-week", discount: 15 } ] ❌ Product 7823765045500 (Dhal makhani): 0/1 compatible plans - INCOMPATIBLE ``` ### Quick Fix Checklist When products aren't showing: 1. **Check Console for "NO VALID REQUIREMENTS" message** - If found: Bundle selling plan extraction is failing - Verify `bundleData.selling_plan_groups` exists - Check if options contain "Order Frequency and Unit" - Verify `price_adjustments` array exists 2. **Check Product Compatibility Logs** - Look for "INCOMPATIBLE" messages - Compare required vs available selling plans - Ensure frequency formats match exactly (e.g., "1-week" vs "1 week") 3. **Validate Data Structure** - Review "FULL BUNDLE DATA STRUCTURE" and "FULL PRODUCT SELLING PLAN STRUCTURE" logs - Ensure option names are consistent between bundle and products - Check that discount percentages match exactly ### Remove Debug Code After Fixing Once the issue is resolved, remove all debug statements and restore normal functionality: ```javascript // Clean version after debugging function hasCompatibleSellingPlans(product, bundleRequirements) { if (!product.selling_plan_groups || bundleRequirements.length === 0) { return false; } const productSellingPlans = []; product.selling_plan_groups.forEach(group => { if (group.selling_plans) { productSellingPlans.push(...group.selling_plans); } }); const compatibleCount = bundleRequirements.filter(requirement => { return productSellingPlans.find(plan => { const frequency = plan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value; const priceAdjustment = plan.price_adjustments?.[0]; return frequency === requirement.frequency && priceAdjustment?.value_type === requirement.priceAdjustment?.value_type && priceAdjustment?.value === requirement.priceAdjustment?.value; }); }).length; return compatibleCount === bundleRequirements.length; } ``` This debugging approach successfully identified and fixed the selling plan matching issue that was causing all products to be filtered out. ## 🎨 OPTIONAL: Price Handling and Currency Normalization ### The Price Problem **Issue**: The bundle data returns prices in cents (e.g., 2000 for $20.00), but bundle widgets need to display them in proper currency format with support for compare-at pricing (strikethrough original prices when subscription discounts apply). ### Solution: Following Reference Widget Patterns Based on the reference widget implementation, here's how to properly handle prices: #### 1. Price Normalization Functions ```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) { 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 && bundleSelections.selectedSellingPlan) { const frequency = bundleSelections.selectedSellingPlan.options?.find(opt => opt.name === "Order Frequency and Unit")?.value || 'subscription'; priceHTML += ` (${frequency})`; } return priceHTML; } ``` #### 2. Product Pricing Calculation Update the product pricing function to normalize all prices from cents: ```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); if (sellingPlanAllocation.compare_at_price) { compareAtPrice = Math.max( normalizePriceFromCents(sellingPlanAllocation.compare_at_price), oneTimePrice ); } } } return { oneTime: oneTimePrice, subscription: subscriptionPrice, compareAt: compareAtPrice, active: bundleSelections.subscribeAndSave ? subscriptionPrice : oneTimePrice, isSubscription: bundleSelections.subscribeAndSave }; } ``` #### 3. Store Normalized Prices When adding items to the bundle selection, 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 }); ``` #### 4. Bundle Summary with Proper Currency Formatting Update all price displays to use proper currency formatting: ```javascript // Individual item totals ${itemsWithPricing.map(item => `
${item.title} Γ— ${item.quantity} ${formatCurrency(item.total)}
`).join('')} // Bundle total
Total Items: ${bundleSelections.totalItems} | Total Price: ${formatCurrency(totalPrice, bundleSelections.items[0]?.currencyCode || 'USD')} ${bundleSelections.subscribeAndSave ? ' (Subscription)' : ' (One-time)'}
``` #### 5. Theme-Driven Compare-At Pricing (Online Store) Do not introduce new CSS for prices. Prefer the theme’s price component/classes and variables (e.g., built-in compare-at styles). If necessary, render a semantic compare-at span and let the theme style it. Example (JS) using a theme-provided compare-at class when available: ```js const root = document.getElementById('bundleWidget'); const compareClass = root?.dataset?.priceCompareClass || 'price-compare-at'; // fallback let priceHTML = ''; if (pricing.isSubscription && pricing.compareAt && pricing.compareAt > pricing.active) { priceHTML += `${formatCurrency(pricing.compareAt)} `; } priceHTML += `${formatCurrency(pricing.active)}${pricing.isSubscription ? '/ delivery' : ''}`; ``` ### Key Reference Widget Patterns The reference widget consistently uses these patterns: 1. **Price Division**: `price / 100` to convert cents to dollars 2. **Currency Formatting**: `this.$n(price, 'currency')` in Vue.js (equivalent to `Intl.NumberFormat`) 3. **Compare-At Logic**: Show strikethrough when `compareAtPrice > activePrice` 4. **Accessibility**: Proper ARIA labels for screen readers ### Example Price Displays **One-time Purchase:** ``` $20.00 ``` **Subscription with Discount:** ``` $25.00 $20.00 (1-week) ^^^ ^^^ ^^^ strikethrough active frequency (original) (discounted) ``` **No Discount Available:** ``` $20.00 (1-week) ^^^ ^^^ active frequency ``` ### Benefits of This Approach 1. **Consistent Formatting**: All prices display in proper currency format 2. **Clear Discounts**: Strikethrough pricing shows savings clearly 3. **Accessibility**: Proper currency formatting works with screen readers 4. **Internationalization**: `Intl.NumberFormat` supports multiple currencies 5. **Reference Compatibility**: Follows the same patterns as the official reference widget This price handling implementation ensures that users see properly formatted prices with clear discount indicators when subscription savings apply. ## 🎨 OPTIONAL: 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: ```js 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) ``` ### Debugging Price Issues If prices aren't updating correctly: 1. **Check Console Logs**: Look for price calculation debug messages 2. **Verify Price Container**: Ensure `[data-price-container]` elements exist in your product cards 3. **Test Toggle**: Verify `updateAllProductPricing()` is called when subscription mode changes 4. **Check Selling Plans**: Ensure products have `selling_plan_allocations` for the selected plan ### Key Success Indicators βœ… **Console shows**: `Applied 20% discount: 8 -> 6.4` βœ… **Console shows**: `Will show strikethrough? YES (compareAt: 8, active: 6.4)` βœ… **UI displays**: ~~$8.00~~ $6.40 (1-week) βœ… **Price updates**: Prices change immediately when toggling subscription mode This complete implementation ensures proper price handling with real-time updates, proper currency formatting, and clear discount indicators for subscription savings. ## πŸ”§ REFERENCE: Real-World 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); } ``` ### Key Benefits of This Approach 1. **Simple and Reliable**: Uses direct selling plan ID matching, which is the most accurate method 2. **Performance**: Fast ID lookup instead of complex semantic comparison 3. **Clear Results**: Only shows products that definitely work with the bundle 4. **No False Positives**: Eliminates products with wrong discount percentages 5. **Better User Experience**: Users only see products they can actually add to their bundle ### Expected Results **Before Filtering:** - 4 products total - 1 with wrong discount percentage (30% vs 20%) - Error: "⚠️ No variants have selling plan allocations matching bundle selling plans" **After Filtering:** - 3 products shown (compatible products only) - All products work with bundle subscription options - Clear console logs showing which products were filtered and why This approach ensures users only see products that are actually compatible with the bundle's selling plans, eliminating confusion and errors during cart submission.