How to Manage Prerender Selling Plan Groups in the Focal Shopify Theme

At the moment, the Focal theme doesn’t include a subscribe form by default, so we’ll need to add one ourselves. Shopify offers a built-in subscription schema, but you can only use it if you’ve signed up for a subscription add-on like Shopify Subscriptions or Recharge.

For example, I once worked with a client who wanted to offer subscription products in certain regions, so those products would only appear where subscriptions were available.

The main thing here is that the subscription form needs to work with all the UI components—like quick buy, the top product form, and the bottom product form. Plus, it should be available as a dynamic UI element, so it shows up whenever someone triggers it with a variation switch or other actions.

To make this work, you’ll want to create a subscription block in your liquid section


{% when 'subscription' %}

{% if product.selected_or_first_available_variant.selling_plan_allocations.size > 0 %}
  <script type="application/json" data-countries>
    {
      "countries": [
        {% for country in localization.available_countries -%}
            {{ country.iso_code }}{% unless forloop.last %},{% endunless %}
        {%- endfor %}
      ]
    }
  </script>
  
  {% if product.variants.size == 1 %}
    {% assign variant_ids = product.selected_or_first_available_variant.selling_plan_allocations | map: 'selling_plan_group_id' | uniq %}
    {% for variant_id in variant_ids %}
      {%liquid
        assign group = product | map: 'selling_plan_groups' | where: 'id', group_id | first
        assign allocations = product.selected_or_first_available_variant | map: 'selling_plan_allocations' | where: 'selling_plan_group_id', group_id
      %}
      {% for allocation in allocations %}
        {%liquid
          assign allocation_price = allocation.price | money | escape
          assign allocation_compared_at_price = allocation.compare_at_price | money | escape
          assign savings = allocation.compare_at_price | minus: allocation.price
          assign savings_percentage = savings | times: 100 | divided_by: allocation.compare_at_price | round: 1
        %}
      {% endfor %}
    {% endfor %}
    <script type="application/json" data-selling-plans>
      {
        "groups": [
          {% for variant_id in variant_ids %}
            {
              "id": "{{ variant_id }}",
              "name": "{{ group.name }}",
              "plans": [
                {% for allocation in allocations %}
                  {
                    "id": "{{ allocation.selling_plan.id }}",
                    "name": "{{ allocation.selling_plan.name }}",
                    "price": "{{ allocation_price }}",
                    "compare_at_price": "{{ allocation_compared_at_price }}",
                    "savings_percentage": {{ savings_percentage }}
                  }{% unless forloop.last %},{% endunless %}
                {% endfor %}
              ]
            }{% unless forloop.last %},{% endunless %}
          {% endfor %}
        ]
      }
    </script>
  {% else %}
    {% assign group_ids = variant.selling_plan_allocations | map: 'selling_plan_group_id' | uniq %}
    {% for group_id in group_ids %}
      {%liquid
        assign group = product | map: 'selling_plan_groups' | where: 'id', group_id | first
        assign allocations = variant | map: 'selling_plan_allocations' | where: 'selling_plan_group_id', group_id
      %}
      {% for allocation in allocations %}
        {%liquid
          assign allocation_price = allocation.price | money | escape
          assign allocation_compared_at_price = allocation.compare_at_price | money | escape
          assign savings = allocation.compare_at_price | minus: allocation.price
          assign savings_percentage = savings | times: 100 | divided_by: allocation.compare_at_price | round: 1
        %}
      {% endfor %}
    {% endfor %}
    <script type="application/json" data-selling-plans>
      {
        "groups": [
          {% for group_id in group_ids %}
            {
              "id": "{{ group_id }}",
              "name": "{{ group.name }}",
              "plans": [
                {% for allocation in allocations %}
                  {
                    "id": "{{ allocation.selling_plan.id }}",
                    "name": "{{ allocation.selling_plan.name }}",
                    "price": "{{ allocation_price }}",
                    "compare_at_price": "{{ allocation_compared_at_price }}",
                    "savings_percentage": {{ savings_percentage }}
                  }{% unless forloop.last %},{% endunless %}
                {% endfor %}
              ]
            }{% unless forloop.last %},{% endunless %}
          {% endfor %}
        ]
      }
    </script>
  {% endif %}
  {% if product.variants.size == 1 %}
    <single-variant>
      <script type="application/json" data-variant>
        {{- product.selected_or_first_available_variant | json -}}
      </script>
    </single-variant>
  {% endif %}
  <subscription-form data-block-type="subscription" data-block-id="{{ section.id }}" handle="{{ product.handle }}" section-id="{{ section.id }}" form-id="{{ product_form_id }}" class="product-form__subscription" {{ block.shopify_attributes }} data-markets="{{ localization.country.iso_code }}">
    <div class="product-form__subscription-form"></div>
  </subscription-form>
{% endif %}

and handle the logic in your theme.js file.

// Custom Subscription Form Start 
var SubscriptionForm = class extends HTMLElement {
  connectedCallback() {
    // Get initial variant ID from the variant picker
    const variantPicker = document.querySelector('variant-picker') || document.querySelector('single-variant');
    if (variantPicker) {
      const initialVariant = variantPicker.querySelector('script[data-variant]');
      if (initialVariant) {
        const variant = JSON.parse(initialVariant.textContent);
        this.subscriptionForm(variant);
      }
    }
    
    // Listen for variant changes
    const form = document.getElementById(this.getAttribute("form-id"));
    form?.addEventListener("variant:changed", this._onVariantChanged.bind(this));
  }

  _onVariantChanged(event) {
    // Get the selected variant from the event. If the first load, the variant will be the first available variant.
    let selectedVariant = event.detail.variant;
    if (!event.detail.previousVariant) {
      // Get the variant picker element which contains all variants
      const variantPicker = document.querySelector('variant-picker') || document.querySelector('single-variant');
      if (variantPicker) {
        // Get the first available variant from the variant picker
        const firstAvailableVariant = variantPicker.querySelector('script[data-variant]');
        if (firstAvailableVariant) {
          selectedVariant = JSON.parse(firstAvailableVariant.textContent);
        }
      }
    }
    // Refresh the subscription form with the selected variant
    this.subscriptionForm(selectedVariant);
  }

  subscriptionForm(selectedVariant) {
    
    // get json data from script with attribute data-selling-plans
    const sellingPlansScript = document.querySelector('script[data-selling-plans]');
    const sellingPlansData = sellingPlansScript ? JSON.parse(sellingPlansScript.textContent) : null;
    
    // Create a copy of the variant to avoid mutating the original
    const variant = JSON.parse(JSON.stringify(selectedVariant));
    
    // Map selling plans to allocations if available
    if (sellingPlansData && sellingPlansData.groups && sellingPlansData.groups[0] && sellingPlansData.groups[0].plans) {
      variant.selling_plan_allocations = variant.selling_plan_allocations.map((allocation, index) => {
        const plan = sellingPlansData.groups[0].plans[index];
        return {
          ...allocation,
          selling_plan_group_id: plan ? plan.name : allocation.selling_plan_group_id
        };
      });
    }
    
    let formWrapper = document.querySelector(".product-form__subscription-form");
    // if selected variant has selling plan allocations, show the selling plan allocations as options selectable
    if (variant.selling_plan_allocations.length > 0) {
      // get calculateServingNumber from variant
      const calculateServingNumber = variant.option2 
        ? parseInt(variant.option2.replace(/[^0-9]/g, '')) 
        : 1;
      // get value of data-markets attribute from subscription-form
      const markets = document.querySelector('subscription-form').getAttribute('data-markets');
      // if markets is not empty, show the selling plan allocations as options selectable
      formWrapper.innerHTML = `
        <div class="subscription-options">
          <div class="purchase-options">
            <div class="purchase-option">
              <input type="radio" id="one-time-purchase" name="purchase_type" value="one-time" ${markets === 'US' ? '' : 'checked'}>
              <label for="one-time-purchase" class="purchase-label">
                <span class="purchase-title">One time purchase</span>
                <span class="purchase-price">
                  <strong>${formatMoney(variant.price)}</strong>
                  <div class="purchase-price-per-serving">${formatMoney(variant.price/calculateServingNumber)} per serving</div>
                </span>  
              </label>
            </div>
          </div>

          ${markets === 'US' ? `
          <div class="subscription-section" ${markets === 'US' ? '' : 'disabled'}>
            <div class="subscription-header">
              <input type="radio" id="subscription-purchase" name="purchase_type" value="subscription" ${markets === 'US' ? 'checked' : ''}>
              <label for="subscription-purchase" class="subscription-label">
                <span class="subscription-title">Set up auto-ship ${variant.selling_plan_allocations.map((allocation, index) => `
                  ${index === 0 ? `
                  <span class="subscription-price">
                    <div>
                      <span class="subscription-price-compare">${formatMoney(allocation.compare_at_price)}</span>
                      <span class="subscription-price-current"><strong>${formatMoney(allocation.price_adjustments[0].price)}</strong></span>
                    </div>
                    <div class="subscription-price-per-serving">${formatMoney(allocation.price_adjustments[0].price/calculateServingNumber)} per serving</div>
                  </span>
                  ` : ''}
                `).join('')}
                </span>
              </label>
            </div>
            
            <div class="subscription-options-list">
              <div class="subscription-options-list-header">
                  <div class="discount-info">
                    <span class="discount-price heading h6">${variant.selling_plan_allocations.map((basePrice, index) => `${index === 0 ? `${formatMoney(basePrice.compare_at_price * 0.9)}` : ''}`).join('')}</span>
                    <span class="discount-percentage heading h5">10% OFF</span>
                    <span class="discount-type heading h6">NOW</span>
                  </div>
                  <div>
                    <svg width="19" height="16" viewBox="0 0 19 16" fill="none" xmlns="http://www.w3.org/2000/svg">
                      <path d="M18.5611 8.70711C18.9516 8.31658 18.9516 7.68342 18.5611 7.29289L12.1971 0.928932C11.8066 0.538408 11.1735 0.538408 10.7829 0.928932C10.3924 1.31946 10.3924 1.95262 10.7829 2.34315L16.4398 8L10.7829 13.6569C10.3924 14.0474 10.3924 14.6805 10.7829 15.0711C11.1735 15.4616 11.8066 15.4616 12.1971 15.0711L18.5611 8.70711ZM0.680664 9H17.854V7H0.680664V9Z" fill="#B3B3B3"></path>
                    </svg>
                  </div>
                  <div class="discount-info">
                    <span class="discount-price heading h6">${variant.selling_plan_allocations.map((basePrice, index) => `${index === 0 ? `${formatMoney(basePrice.compare_at_price * 0.8)}` : ''}`).join('')}</span>
                    <span class="discount-percentage heading h5">20% OFF</span>
                    <span class="discount-type heading h6">NEXT ORDER</span>
                  </div>
                  <div>
                    <svg width="19" height="16" viewBox="0 0 19 16" fill="none" xmlns="http://www.w3.org/2000/svg">
                      <path d="M18.5611 8.70711C18.9516 8.31658 18.9516 7.68342 18.5611 7.29289L12.1971 0.928932C11.8066 0.538408 11.1735 0.538408 10.7829 0.928932C10.3924 1.31946 10.3924 1.95262 10.7829 2.34315L16.4398 8L10.7829 13.6569C10.3924 14.0474 10.3924 14.6805 10.7829 15.0711C11.1735 15.4616 11.8066 15.4616 12.1971 15.0711L18.5611 8.70711ZM0.680664 9H17.854V7H0.680664V9Z" fill="#B3B3B3"></path>
                    </svg>
                  </div>
                  <div class="discount-info">
                    <span class="discount-price heading h6">${variant.selling_plan_allocations.map((basePrice, index) => `${index === 0 ? `${formatMoney(basePrice.compare_at_price * 0.7)}` : ''}`).join('')}</span>
                    <span class="discount-percentage heading h5">30% OFF</span>
                    <span class="discount-type heading h6">3+ ORDERS</span>
                  </div>
              </div>
              <select name="selling_plan" class="subscription-select">
                ${variant.selling_plan_allocations.map((allocation, index) => `
                  <option 
                    value="${allocation.selling_plan_id}"
                    data-price="${allocation.price}"
                    ${index === 0 ? 'selected' : ''}>
                    ${allocation.selling_plan_group_id}
                  </option>
                `).join('')}
              </select>
            </div>
          </div>
          ` : `
          
          `}
        </div>
      `;

      // Add event listeners for purchase type selection
      const purchaseTypeRadios = formWrapper.querySelectorAll('input[name="purchase_type"]');
      // get value of checked purchase_type input
      const checkedPurchaseType = Array.from(purchaseTypeRadios).find(radio => radio.checked).value;
      
      // Get all buy buttons containers
      const productBuyButtons = document.querySelectorAll('div[data-block-type="buy-buttons"]');
      const quickBuyDrawerElement = document.querySelector('quick-buy-drawer');
      const quickBuyPopoverElement = document.querySelector('quick-buy-popover');
      const productStickyElement = document.querySelector('product-sticky-form');
      
      // iOS-optimized button text update function
      const updateButtonText = (container) => {
        const submitButtonTexts = container.querySelectorAll('button[type="submit"] span.loader-button__text');
        const newText = checkedPurchaseType === 'subscription' ? 'Subscribe' : 'Add to cart';
        
        submitButtonTexts.forEach(textElement => {
          // iOS Safari fix: Use innerHTML instead of textContent for better rendering
          if (textElement.innerHTML !== newText) {
            textElement.innerHTML = newText;
            
            // Force reflow on iOS Safari to ensure text update is rendered
            if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
              textElement.style.display = 'none';
              textElement.offsetHeight; // Force reflow
              textElement.style.display = '';
            }
          }
        });
      };

      // Update buttons in both regular product page and quick-buy-drawer
      const initializeButtonTexts = () => {
        productBuyButtons.forEach(container => updateButtonText(container));
        if (quickBuyDrawerElement) {
          updateButtonText(quickBuyDrawerElement);
        }
        if (quickBuyPopoverElement) {
          updateButtonText(quickBuyPopoverElement);
        }
        if (productStickyElement) {
          updateButtonText(productStickyElement);
        }
      };

      // Use requestAnimationFrame to ensure DOM is ready
      requestAnimationFrame(() => {
        initializeButtonTexts();
        // Also try again after a short delay to catch any late-loading elements
        setTimeout(initializeButtonTexts, 100);
      });

      // Add MutationObserver to handle dynamically loaded content
      const observer = new MutationObserver((mutations) => {
        let shouldUpdate = false;
        mutations.forEach((mutation) => {
          if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
            // Check if any new button elements were added
            mutation.addedNodes.forEach((node) => {
              if (node.nodeType === Node.ELEMENT_NODE) {
                if (node.matches && (node.matches('button[type="submit"]') || node.querySelector('button[type="submit"]'))) {
                  shouldUpdate = true;
                }
              }
            });
          }
        });
        
        if (shouldUpdate) {
          // Small delay to ensure new elements are fully rendered
          setTimeout(initializeButtonTexts, 50);
        }
      });

      // Observe the document body for new elements
      observer.observe(document.body, {
        childList: true,
        subtree: true
      });

      purchaseTypeRadios.forEach(radio => {
        radio.addEventListener('change', (event) => {
          const selectedType = event.target.value;
          // Enable/disable subscription select based on selection
          const subscriptionSelectContainer = formWrapper.querySelector('.subscription-options-list');
          const subscriptionSelect = formWrapper.querySelector('.subscription-select');
          subscriptionSelect.disabled = selectedType !== 'subscription';
          subscriptionSelectContainer.style.display = selectedType !== 'subscription' ? 'none' : 'block';
          
          // Update button text in both regular product page and quick-buy-drawer
          productBuyButtons.forEach(container => {
            const submitButtonTexts = container.querySelectorAll('button[type="submit"] span.loader-button__text');
            submitButtonTexts.forEach(textElement => {
              // iOS Safari fix: Use innerHTML instead of textContent for better rendering
              const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
              if (textElement.innerHTML !== newText) {
                textElement.innerHTML = newText;
                
                // Force reflow on iOS Safari to ensure text update is rendered
                if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
                  textElement.style.display = 'none';
                  textElement.offsetHeight; // Force reflow
                  textElement.style.display = '';
                }
              }
            });
          });
          
          if (quickBuyDrawerElement) {
            const submitButtonTexts = quickBuyDrawerElement.querySelectorAll('button[type="submit"] span.loader-button__text');
            submitButtonTexts.forEach(textElement => {
              // iOS Safari fix: Use innerHTML instead of textContent for better rendering
              const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
              if (textElement.innerHTML !== newText) {
                textElement.innerHTML = newText;
                
                // Force reflow on iOS Safari to ensure text update is rendered
                if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
                  textElement.style.display = 'none';
                  textElement.offsetHeight; // Force reflow
                  textElement.style.display = '';
                }
              }
            });
          }

          if (quickBuyPopoverElement) {
            const submitButtonTexts = quickBuyPopoverElement.querySelectorAll('button[type="submit"] span.loader-button__text');
            submitButtonTexts.forEach(textElement => {
              // iOS Safari fix: Use innerHTML instead of textContent for better rendering
              const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
              if (textElement.innerHTML !== newText) {
                textElement.innerHTML = newText;
                
                // Force reflow on iOS Safari to ensure text update is rendered
                if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
                  textElement.style.display = 'none';
                  textElement.offsetHeight; // Force reflow
                  textElement.style.display = '';
                }
              }
            });
          }
          
          if (productStickyElement) {
            const submitButtonTexts = productStickyElement.querySelectorAll('button[type="submit"] span.loader-button__text');
            submitButtonTexts.forEach(textElement => {
              // iOS Safari fix: Use innerHTML instead of textContent for better rendering
              const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
              if (textElement.innerHTML !== newText) {
                textElement.innerHTML = newText;
                
                // Force reflow on iOS Safari to ensure text update is rendered
                if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
                  textElement.style.display = 'none';
                  textElement.offsetHeight; // Force reflow
                  textElement.style.display = '';
                }
              }
            });
          }

          // If switching to subscription, select the first option
          if (selectedType === 'subscription') {
            subscriptionSelect.selectedIndex = 0;
            // get form inside each div with attribute data-block-type="buy-buttons" and add hidden input with name "selling_plan" and value of the selected selling plan
            productBuyButtons.forEach(buyButton => {
              const form = buyButton.querySelector('form');
              // get input with name "selling_plan" and set value to the selected selling plan
              const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
              sellingPlanInput.value = subscriptionSelect.value;
              // get button Submit Text
              const submitText = form.querySelector('button[type="submit"]');
              const submitTextValue = submitText.querySelector('span.loader-button__text');
              // change submit text to "Set up auto-ship"
              // iOS Safari fix: Use innerHTML instead of textContent for better rendering
              if (submitTextValue.innerHTML !== 'Subscribe') {
                submitTextValue.innerHTML = 'Subscribe';
                
                // Force reflow on iOS Safari to ensure text update is rendered
                if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
                  submitTextValue.style.display = 'none';
                  submitTextValue.offsetHeight; // Force reflow
                  submitTextValue.style.display = '';
                }
              }
            });
          } else {
            // get form inside each div with attribute data-block-type="buy-buttons" and add hidden input with name "selling_plan" and value of the selected selling plan
            productBuyButtons.forEach(buyButton => {
              const form = buyButton.querySelector('form');
              // get input with name "selling_plan" and set value to the selected selling plan
              const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
              sellingPlanInput.value = '';
              // get button Submit Text
              const submitText = form.querySelector('button[type="submit"]');
              const submitTextValue = submitText.querySelector('span.loader-button__text');
              // change submit text to "Set up auto-ship"
              // iOS Safari fix: Use innerHTML instead of textContent for better rendering
              if (submitTextValue.innerHTML !== 'Add to cart') {
                submitTextValue.innerHTML = 'Add to cart';
                
                // Force reflow on iOS Safari to ensure text update is rendered
                if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
                  submitTextValue.style.display = 'none';
                  submitTextValue.offsetHeight; // Force reflow
                  submitTextValue.style.display = '';
                }
              }
            });
          }
        });
      });

      // Add event listener for subscription plan selection
      const subscriptionSelect = formWrapper.querySelector('.subscription-select');
      subscriptionSelect.addEventListener('change', (event) => {
        // get form inside each div with attribute data-block-type="buy-buttons" and add hidden input with name "selling_plan" and value of the selected selling plan
        productBuyButtons.forEach(buyButton => {
          const form = buyButton.querySelector('form');
          // get input with name "selling_plan" and set value to the selected selling plan
          const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
          sellingPlanInput.value = subscriptionSelect.value;
        });
      });

      // Initialize the form with subscription selected
      productBuyButtons.forEach(buyButton => {
        const form = buyButton.querySelector('form');
        const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
        sellingPlanInput.value = subscriptionSelect.value;
      });
    }
  }
  
};
window.customElements.define("subscription-form", SubscriptionForm);
// Custom Subscription Form End

This article is intended for med-exp Shopify Specialist Engineers and does not cover all details related to adding subscriptions to the page form block or customizing styles, as these aspects can be adjusted based on individual theme requirements. For further assistance, a Shopify partner invitation can be sent to [email protected].