Firmhouse for Developers
FirmhouseHelp CenterStatusPortal / Login
  • ❤️Firmhouse for Developers
  • GraphQL API
    • Introduction
    • Getting Started
    • Pagination
    • Handling Errors
    • API Reference
      • Queries
        • assets
        • currentCart
        • discountCodes
        • getCollectionCase
        • getCurrentProject
        • getDiscountCode
        • getInvoice
        • getOrder
        • getOrderBy
        • getOrderCalculations
        • getOrders
        • getPayment
        • getRefund
        • getServiceChannelBy
        • getSubscription
        • getSubscriptionAccount
        • getSubscriptionBySelfServiceCenterLoginToken
        • getSubscriptions
        • invoices
        • orderedProducts
        • orders
        • outstandingInvoices
        • payments
        • plans
        • products
        • returnOrders
        • subscriptionAccounts
        • subscriptions
      • Mutations
        • activateSubscription
        • applyPromotionToSubscription
        • assignAsset
        • cancelReturnOrder
        • cancelSubscription
        • completeReturnOrder
        • completeSubscriptionCancellation
        • createAsset
        • createCart
        • createDiscountCode
        • createInvoiceLineItem
        • createInvoicedOneTimeCharge
        • createOrder
        • createOrderedProduct
        • createOrderedProductV2
        • createProduct
        • createPromotion
        • createReturnOrder
        • createSelfServiceCenterLoginToken
        • createSelfServiceCenterLoginTokenV2
        • createServiceChannel
        • createSubscription
        • createSubscriptionFromCart
        • creditAndRefundInvoice
        • deactivateAppliedPromotion
        • destroyInvoiceLineItem
        • destroyOrderedProduct
        • editPlan
        • fulfillOrder
        • fulfillOrders
        • importSubscription
        • pauseSubscription
        • refundPayment
        • resumeSubscription
        • sendSelfServiceCenterLoginTokenEmail
        • shipOrderedProducts
        • switchSubscriptionPlan
        • syncShopifyProducts
        • unassignAsset
        • undoSubscriptionCancellation
        • updateAddressDetails
        • updateAppliedPromotion
        • updateAsset
        • updateAssetOwnership
        • updateInvoice
        • updateInvoiceLineItem
        • updateOrder
        • updateOrderedProduct
        • updateOrderedProductQuantity
        • updatePlan
        • updateProduct
        • updatePromotion
        • updateReturnOrder
        • updateShippingCostsExemption
        • updateSubscribedPlan
        • updateSubscription
      • Objects
        • AcceptanceCheck
        • AcceptanceCheckStatus
        • ActivateSubscriptionInput
        • ActivateSubscriptionPayload
        • AdyenPaymentMethodVariant
        • AppliedBillingCyclePromotion
        • AppliedOrderDiscountPromotion
        • AppliedPromotion
        • AppliedPromotionDeactivationStrategy
        • ApplyPromotionToSubscriptionInput
        • ApplyPromotionToSubscriptionPayload
        • Asset
        • AssetConnection
        • AssetCustomField
        • AssetCustomFieldInput
        • AssetCustomFieldValue
        • AssetEdge
        • AssetOwnership
        • AssetStatus
        • AssignAssetInput
        • AssignAssetPayload
        • BaseIntervalUnit
        • BillingCycleIntervalUnit
        • BillingCyclePromotion
        • Boolean
        • CancelReturnOrderInput
        • CancelReturnOrderPayload
        • CancelSubscriptionInput
        • CancelSubscriptionPayload
        • Cart
        • CollectionCase
        • CollectionCaseConnection
        • CollectionCaseEdge
        • CollectionCaseStatus
        • CommitmentUnit
        • CompleteReturnOrderInput
        • CompleteReturnOrderPayload
        • CompleteSubscriptionCancellationInput
        • CompleteSubscriptionCancellationPayload
        • CreateAssetInput
        • CreateAssetPayload
        • CreateCartInput
        • CreateCartPayload
        • CreateDiscountCodeInput
        • CreateDiscountCodePayload
        • CreateInvoiceLineItemInput
        • CreateInvoiceLineItemPayload
        • CreateInvoicedOneTimeChargeInput
        • CreateInvoicedOneTimeChargePayload
        • CreateOrderInput
        • CreateOrderPayload
        • CreateOrderedProductInput
        • CreateOrderedProductPayload
        • CreateOrderedProductV2Input
        • CreateOrderedProductV2Payload
        • CreateProductInput
        • CreateProductPayload
        • CreatePromotionInput
        • CreatePromotionPayload
        • CreateReturnOrderInput
        • CreateReturnOrderPayload
        • CreateSelfServiceCenterLoginTokenInput
        • CreateSelfServiceCenterLoginTokenPayload
        • CreateSelfServiceCenterLoginTokenV2Input
        • CreateSelfServiceCenterLoginTokenV2Payload
        • CreateServiceChannelInput
        • CreateServiceChannelPayload
        • CreateSubscriptionFromCartInput
        • CreateSubscriptionFromCartPayload
        • CreateSubscriptionInput
        • CreateSubscriptionPayload
        • CreditAndRefundInvoiceInput
        • CreditAndRefundInvoicePayload
        • CustomerFeedback
        • DeactivateAppliedPromotionInput
        • DeactivateAppliedPromotionPayload
        • DestroyInvoiceLineItemInput
        • DestroyInvoiceLineItemPayload
        • DestroyOrderedProductInput
        • DestroyOrderedProductPayload
        • DiscountCode
        • DiscountCodeConnection
        • DiscountCodeEdge
        • EditPlanInput
        • EditPlanPayload
        • ExtraField
        • ExtraFieldAnswer
        • ExtraFieldInput
        • ExtraFieldInterface
        • FeedbackTypeEnum
        • Float
        • FulfillOrderInput
        • FulfillOrderPayload
        • FulfillOrdersInput
        • FulfillOrdersPayload
        • ID
        • ISO8601Date
        • ISO8601DateTime
        • ImportSubscriptionInput
        • ImportSubscriptionPayload
        • InstalmentIntervalInterface
        • Int
        • Invoice
        • InvoiceConnection
        • InvoiceEdge
        • InvoiceLineItem
        • InvoiceReminder
        • InvoiceStatusEnum
        • InvoiceUpdatableStatusEnum
        • JSON
        • Licence
        • LineItemTypeEnum
        • MaximumCommitmentUnit
        • MetadataInterface
        • ModelValidationError
        • Offer
        • OfferTypeEnum
        • Order
        • OrderCalculation
        • OrderConnection
        • OrderDiscountPromotion
        • OrderEdge
        • OrderLine
        • OrderSortEnum
        • OrderStatus
        • OrderedProduct
        • OrderedProductEdge
        • OrderedProductInput
        • OrderedProductIntervalUnitOfMeasure
        • OrderedProductStatus
        • OrderedProductTypes
        • OrderedProductsWithSummaryConnection
        • PageInfo
        • PauseSubscriptionInput
        • PauseSubscriptionPayload
        • Payment
        • PaymentConnection
        • PaymentEdge
        • PaymentStatusEnum
        • PaymentTypeEnum
        • Plan
        • PlanConnection
        • PlanEdge
        • PlanProduct
        • Price
        • PriceDeleteInput
        • PriceInput
        • Product
        • ProductConnection
        • ProductEdge
        • Project
        • Promotion
        • Refund
        • RefundPaymentInput
        • RefundPaymentPayload
        • RefundStatus
        • ResumeSubscriptionInput
        • ResumeSubscriptionPayload
        • ReturnOrder
        • ReturnOrderConnection
        • ReturnOrderEdge
        • ReturnOrderProduct
        • ReturnOrderProductInput
        • ReturnOrderStatus
        • SelfServiceCenterLoginToken
        • SendSelfServiceCenterLoginTokenEmailInput
        • SendSelfServiceCenterLoginTokenEmailPayload
        • ServiceChannel
        • ShipOrderedProductsInput
        • ShipOrderedProductsPayload
        • String
        • SubscribedPlan
        • Subscription
        • SubscriptionAcceptanceCheck
        • SubscriptionAcceptanceCheckConnection
        • SubscriptionAcceptanceCheckEdge
        • SubscriptionAccount
        • SubscriptionAccountConnection
        • SubscriptionAccountEdge
        • SubscriptionAccountStatus
        • SubscriptionConnection
        • SubscriptionEdge
        • SubscriptionIdentity
        • SubscriptionStatus
        • SwitchSubscriptionPlanInput
        • SwitchSubscriptionPlanPayload
        • SyncShopifyProductsInput
        • SyncShopifyProductsPayload
        • TaxRate
        • UnassignAssetInput
        • UnassignAssetPayload
        • UndoSubscriptionCancellationInput
        • UndoSubscriptionCancellationPayload
        • UpdateAddressDetailsInput
        • UpdateAddressDetailsPayload
        • UpdateAppliedPromotionInput
        • UpdateAppliedPromotionPayload
        • UpdateAssetInput
        • UpdateAssetOwnershipInput
        • UpdateAssetOwnershipPayload
        • UpdateAssetPayload
        • UpdateInvoiceInput
        • UpdateInvoiceLineItemInput
        • UpdateInvoiceLineItemPayload
        • UpdateInvoicePayload
        • UpdateOrderInput
        • UpdateOrderPayload
        • UpdateOrderedProductInput
        • UpdateOrderedProductPayload
        • UpdateOrderedProductQuantityInput
        • UpdateOrderedProductQuantityPayload
        • UpdatePlanInput
        • UpdatePlanPayload
        • UpdateProductInput
        • UpdateProductPayload
        • UpdatePromotionInput
        • UpdatePromotionPayload
        • UpdateReturnOrderInput
        • UpdateReturnOrderPayload
        • UpdateShippingCostsExemptionInput
        • UpdateShippingCostsExemptionPayload
        • UpdateSubscribedPlanInput
        • UpdateSubscribedPlanPayload
        • UpdateSubscriptionInput
        • UpdateSubscriptionPayload
  • Liquid
    • Introduction
    • Email template overview
    • Using Extra Fields in Liquid
    • Available tags
  • Webhooks
    • Overview
  • Guides
    • Creating a Storefront App
    • Updating the amount of discount on each order
    • Build a switch/upgrade plan flow that requires payment
  • SDKs
    • Storefront JS SDK
      • Configuration Options
      • Create a Storefront API token
      • Shopify
        • Add an integrated cart to your Shopify store
        • Link up "View Cart" button
      • Translations and custom copy
      • Using with Next.js
    • Firmhouse SDK
      • Getting Started
      • Examples and Boilerplates
      • Handling Errors
      • Reference
        • Classes
          • FirmhouseClient
        • Resources
          • CartsResource
          • InvoicesResource
          • PlansResource
          • ProductsResource
          • ProjectsResource
          • SelfServiceCenterTokenResource
          • SubscriptionsResource
        • Errors
          • NotFoundError
          • ServerError
          • ValidationError
        • Enumerations
          • AcceptanceCheckStatus
          • Access
          • AppliedPromotionDeactivationStrategy
          • AssetStatus
          • BaseIntervalUnit
          • BillingCycleIntervalUnit
          • CollectionCaseStatus
          • CommitmentUnit
          • FeedbackTypeEnum
          • InvoiceStatusEnum
          • LineItemTypeEnum
          • MaximumCommitmentUnit
          • OrderStatus
          • OrderedProductIntervalUnitOfMeasure
          • OrderedProductStatus
          • OrderedProductTypes
          • PaymentStatusEnum
          • PaymentTypeEnum
          • RefundStatus
          • ReturnOrderStatus
          • SubscriptionStatus
        • Functions
          • mapExtraFieldsByFieldId()
        • Interfaces
          • FirmhouseAppliedOrderDiscountPromotion
          • FirmhouseAppliedPromotion
          • FirmhouseBillingCyclePromotion
          • FirmhouseCart
          • FirmhouseCollectionCase
          • FirmhouseConfig
          • FirmhouseDiscountCode
          • FirmhouseExtraField
          • FirmhouseExtraFieldAnswer
          • FirmhouseInvoice
          • FirmhouseInvoiceLineItem
          • FirmhouseInvoiceReminder
          • FirmhouseOrder
          • FirmhouseOrderLine
          • FirmhouseOrderedProduct
          • FirmhouseOrderedProductUtils
          • FirmhouseOrderedProductWithUtils
          • FirmhouseOriginalInvoice
          • FirmhousePayment
          • FirmhousePlan
          • FirmhouseProduct
          • FirmhouseProject
          • FirmhousePromotion
          • FirmhouseRefund
          • FirmhouseSubscribedPlan
          • FirmhouseSubscription
          • FirmhouseSubscriptionUtils
          • FirmhouseSubscriptionWithUtils
          • FirmhouseTaxRate
          • FirmhouseVerifiedIdentity
          • FirmouseCollectionCase
        • TypeAliases
          • PaginatedResponse
    • Headless React
      • Examples
      • Components
        • FirmhouseCartProvider
        • CheckoutForm
        • OrderedProductList
        • OrderSummary
        • Translated
Powered by GitBook
On this page
  • Before you begin
  • Let's get started
  • Add the Selling Plan Selector to Your Product Page Template
  • Customizations for using SEPA based payment methods during checkout

Was this helpful?

  1. Guides
  2. Setting up Shopify Native Checkout

Adding selling plan options to your Shopify product pages

This guide will help you integrate Selling Plan options into your Shopify store’s product pages and add an additional checkout button for SEPA payment methods for subscriptions.

Last updated 5 months ago

Was this helpful?

Before you begin

  • First, follow the to connect your store to Firmhouse

Let's get started

This article is based on . If you are using another theme or version, the steps might be slightly different.

To update your theme without affecting the live version, duplicate it, make your changes, and publish the updated version when ready.

Add the Selling Plan Selector to Your Product Page Template

  1. Create /snippets/selling-plans-integration.liquid. This snippet defines the selling plan selector.

/snippets/selling-plans-integration.liquid
{% assign firmhouse_app_id = '4631435' %}
{% if product.selling_plan_groups.size > 0 %}
  {% liquid
    assign app_string = 'app--' | append: firmhouse_app_id | append: '--firmhouse'
    assign native_selling_plans = shop.metaobjects[app_string].subscriptions.selling_plan_mapping.value | map: 'native'
    assign current_variant = product.selected_or_first_available_variant | default: product.variants.first
  %}
  <div class="selling_plan_app_container" data-section-id="{{ section.id }}">
    <script src="{{ 'selling-plans-integration.js' | asset_url }}" defer></script>
    <style>
      .selling_plan_theme_integration--hidden {
        display: none;
      }
      .selling_plan_app_plans_list {
        list-style: none;
        padding: 0;
        margin: 0;
      }
    </style>
    {% for variant in product.variants %}
      {% liquid
        assign variant_price = variant.price | money_with_currency | escape
        assign variant_compared_at_price = variant.compare_at_price | money_with_currency | escape
      %}
      {% if variant.selling_plan_allocations.size > 0 %}
        <section
          data-variant-id="{{ variant.id }}"
          class="selling_plan_theme_integration {% if variant.id != current_variant.id %}selling_plan_theme_integration--hidden{% endif %}"
        >
          <fieldset>
            <legend>
              Frequency
            </legend>
            <div>
              <div>
                <ul class="selling_plan_app_plans_list">
                  {% unless product.requires_selling_plan %}
                    <li>
                      <input
                        id="sp-one-time-purchase"
                        aria-label="One-time purchase. Product price {{ variant_price }}"
                        type="radio"
                        name="purchaseOption_{{ section.id }}_{{ variant.id }}"
                        {% if variant.available == false %}
                          disabled
                        {% endif %}
                        id="{{ section.id }}_one_time_purchase"
                        data-radio-type="one_time_purchase"
                        data-variant-id="{{ variant.id }}"
                        data-variant-price="{{ variant_price }}"
                        data-variant-compare-at-price="{{ variant_compared_at_price }}"
                        checked
                      >
                      <label for="sp-one-time-purchase">One-time purchase</label>
                    </li>
                  {% endunless %}
                  {% 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

                      if forloop.first
                        assign first_selling_plan_group = true
                      else
                        assign first_selling_plan_group = false
                      endif
                    %}
                    {% for allocation in allocations %}
                      {% assign selling_plan_id = 'gid://shopify/SellingPlan/' | append: allocation.selling_plan.id %}
                      {% unless native_selling_plans contains selling_plan_id %}
                        {% continue %}
                      {% endunless %}
                      {% liquid
                        if forloop.first and product.requires_selling_plan and first_selling_plan_group
                          assign plan_checked = 'checked'
                        else
                          assign plan_checked = null
                        endif

                        assign allocation_price = allocation.price | money_with_currency | escape
                        assign allocation_compared_at_price = allocation.compare_at_price | money_with_currency | escape
                        assign input_id = 'sp-' | append: variant.id | append: '-' | append: allocation.selling_plan.id
                      %}
                      <li>
                        <input
                          id="{{ input_id }}"
                          type="radio"
                          {% if variant.available == false %}
                            disabled
                          {% endif %}
                          aria-label="{{ allocation.selling_plan.name }}. Product price {{ allocation_price }}"
                          name="purchaseOption_{{ section.id }}_{{ variant.id }}"
                          data-radio-type="selling_plan"
                          data-selling-plan-id="{{ allocation.selling_plan.id }}"
                          data-selling-plan-group-id="{{ section.id }}_{{ group_id }}_{{ variant.id }}"
                          data-selling-plan-adjustment="{{ allocation.selling_plan.price_adjustments.size }}"
                          data-variant-price="{{ allocation_price }}"
                          data-variant-compare-at-price="{{ allocation_compared_at_price }}"
                          {{ plan_checked }}
                        >
                        <label for="{{ input_id }}">
                          {{ allocation.selling_plan.name }}
                        </label>
                      </li>
                    {% endfor %}
                  {% endfor %}
                </ul>
              </div>
            </div>
          </fieldset>
        </section>
      {% endif %}
    {% endfor %}
  </div>
  <input
    name="selling_plan"
    class="selected-selling-plan-id"
    type="hidden"
  >
{% endif %}
  1. Create /assets/selling-plans-integration.js. This file contains the logic for the selling plan selector.

/assets/selling-plans-integration.js
const hiddenClass = 'selling_plan_theme_integration--hidden';

class SellingPlansWidget {
  constructor(sellingPlansWidgetContainer) {
    this.enablePerformanceObserver();
    this.sellingPlansWidgetContainer = sellingPlansWidgetContainer;
    this.appendSellingPlanInputs();
    this.updateSellingPlanInputsValues();
    this.listenToVariantChange();
    this.listenToSellingPlanFormRadioButtonChange();
    this.updatePrice();
  }

  get sectionId() {
    return this.sellingPlansWidgetContainer.getAttribute('data-section-id');
  }

  get shopifySection() {
    return document.querySelector(`#shopify-section-${this.sectionId}`);
  }

  /*
    We are careful to target the correct form, as there are instances when we encounter an installment form that we specifically aim to avoid interacting with.
  */
  get variantIdInput() {
    return (
      this.addToCartForms[1]?.querySelector(`input[name="id"]`) ||
      this.addToCartForms[1]?.querySelector(`select[name="id"]`) ||
      this.addToCartForms[0].querySelector(`input[name="id"]`) ||
      this.addToCartForms[0].querySelector(`select[name="id"]`)
    );
  }

  get priceElement() {
    return this.shopifySection.querySelector('.price');
  }

  get comparedAtPrice() {
    return this.shopifySection.querySelector('.price__sale');
  }

  get visibleSellingPlanForm() {
    return this.shopifySection.querySelector(
      `section[data-variant-id^="${this.variantIdInput.value}"]`,
    );
  }

  get isVariantAvailable() {
    return this.selectedPurchaseOption.getAttributeNames().includes('disabled');
  }

  get sellingPlanInput() {
    return this.shopifySection.querySelector('.selected-selling-plan-id');
  }

  get addToCartForms() {
    return this.shopifySection.querySelectorAll('[action*="/cart/add"]');
  }

  /*
    To enable the addition of a selling plan to a cart, it's necessary to include an input with the name "selling_plan", which will carry the selling ID as its value. When a buyer clicks on 'add to cart', the appropriate selling plan ID is added to their cart.
  */
  appendSellingPlanInputs() {
    this.addToCartForms.forEach((addToCartForm) => {
      addToCartForm.appendChild(this.sellingPlanInput.cloneNode());
    });
  }

  showSellingPlanForm(sellingPlanFormForSelectedVariant) {
    sellingPlanFormForSelectedVariant?.classList?.remove(hiddenClass);
  }

  hideSellingPlanForms(sellingPlanFormsForUnselectedVariants) {
    sellingPlanFormsForUnselectedVariants.forEach((element) => {
      element.classList.add(hiddenClass);
    });
  }

  /*
    Each product variant comes with a selling plan selection box that the buyer can interact with.
    When a buyer chooses a different variant, we ensure that only the relevant selling plan selection box is displayed.
    This guarantees that only the selling plan associated with the selected variant is shown.
  */
  handleSellingPlanFormVisibility() {
    const sellingPlanFormForSelectedVariant = this.shopifySection.querySelector(
      `section[data-variant-id="${this.variantIdInput.value}"]`,
    );
    const sellingPlanFormsForUnselectedVariants =
      this.shopifySection.querySelectorAll(
        `.selling_plan_theme_integration:not([data-variant-id="${this.variantIdInput.value}"])`,
      );
    this.showSellingPlanForm(sellingPlanFormForSelectedVariant);
    this.hideSellingPlanForms(sellingPlanFormsForUnselectedVariants);
  }

  handleVariantChange() {
    this.handleSellingPlanFormVisibility();
    this.updateSellingPlanInputsValues();
    this.listenToSellingPlanFormRadioButtonChange();
  }

  /*
    The functions listenToVariantChange() and listenToAddToCartForms() are implemented to track when a product variant is altered or when the product form is updated.
    The identification of the variant is crucial as it dictates which selling plan box should be displayed.
  */
  listenToVariantChange() {
    this.listenToAddToCartForms();
    if (this.variantIdInput.tagName === 'INPUT') {
      const variantIdObserver = new MutationObserver((mutationList) => {
        mutationList.forEach((mutationRecord) => {
          this.handleVariantChange(mutationRecord.target.value);
        });
      });

      variantIdObserver.observe(this.variantIdInput, {
        attributes: true,
      });
    }
  }

  listenToAddToCartForms() {
    this.addToCartForms.forEach((addToCartForm) => {
      addToCartForm.addEventListener('change', () => {
        this.handleVariantChange();
      });
    });
  }

  get regularPriceElement() {
    return this.shopifySection.querySelector('.price__regular');
  }

  get salePriceElement() {
    return this.shopifySection.querySelector('.price__sale');
  }

  get salePriceValue() {
    return this.salePriceElement.querySelector('.price-item--sale');
  }

  get regularPriceValue() {
    return this.salePriceElement.querySelector('.price-item--regular');
  }

  get sellingPlanAllocationPrice() {
    return document.getElementById(
      `${this.selectedPurchaseOption.dataset.sellingPlanGroupId}_allocation_price`,
    );
  }

  get selectedPurchaseOptionPrice() {
    return this.selectedPurchaseOption.dataset.variantPrice;
  }

  get selectedPurchaseOptionComparedAtPrice() {
    return this.selectedPurchaseOption.dataset.variantCompareAtPrice;
  }

  get price() {
    return this.sellingPlanAllocationPrices.price ?? null;
  }

  /*
    We aim to ascertain whether a compared price exists, which would indicate that the currently selected input has a discount applied to it.
    If a discount is detected, the discounted price is displayed; otherwise, the regular price is shown.
  */
  updatePrice() {
    if (
      !this.selectedPurchaseOptionComparedAtPrice ||
      this.selectedPurchaseOptionComparedAtPrice ===
        this.selectedPurchaseOptionPrice
    ) {
      this.showRegularPrice();
      this.hideSalePrice();
      this.priceElement.classList.remove('price--on-sale');
    } else {
      this.showSalePrice();
      this.hideRegularPrice();
      this.priceElement.classList.add('price--on-sale');
    }
  }

  hideSalePrice() {
    this.salePriceElement.style.display = 'none';
  }

  hideRegularPrice() {
    this.regularPriceElement.style.display = 'none';
  }

  showRegularPrice() {
    this.regularPriceElement.style.display = 'block';
    this.shopifySection.querySelector('.price__sale').style.display = 'none';
  }

  showSalePrice() {
    this.salePriceElement.style.display = 'block';
    this.regularPriceValue.innerHTML =
      this.selectedPurchaseOptionComparedAtPrice;
    this.salePriceValue.innerHTML = this.selectedPurchaseOptionPrice;
  }

  get sellingPlanInputs() {
    return this.shopifySection.querySelectorAll('.selected-selling-plan-id');
  }

  updateSellingPlanInputsValues() {
    this.sellingPlanInputs.forEach((sellingPlanInput) => {
      sellingPlanInput.value = this.sellingPlanInputValue;
    });
  }

  get sellingPlanInputValue() {
    return this.selectedPurchaseOption?.dataset.sellingPlanId ?? null;
  }

  get selectedPurchaseOption() {
    return this.visibleSellingPlanForm?.querySelector(
      'input[type="radio"]:checked',
    );
  }

  set selectedPurchaseOption(selectedPurchaseOption) {
    this._selectedPurchaseOption = selectedPurchaseOption;
  }

  handleRadioButtonChange(selectedPurchaseOption) {
    this.selectedPurchaseOption = selectedPurchaseOption;
    this.updateSellingPlanInputsValues();
    this.updatePrice();
  }

  listenToSellingPlanFormRadioButtonChange() {
    this.visibleSellingPlanForm
      ?.querySelectorAll('input[type="radio"]')
      .forEach((radio) => {
        radio.addEventListener('change', (event) => {
          this.handleRadioButtonChange(event.target);
        });
      });
  }

  enablePerformanceObserver() {
    const performanceObserver = new PerformanceObserver((list) => {
      list.getEntries().forEach((entry) => {
        if (entry.initiatorType !== 'fetch') return;

        const url = new URL(entry.name);
        /*
          When a buyer selects a product variant, a fetch request is initiated.
          Upon completion of this fetch request, we update the price to reflect the correct value.
        */
        if (url.search.includes('variant') || url.search.includes('variants')) {
          this.updatePrice();
        }
      });
    });

    performanceObserver.observe({entryTypes: ['resource']});
  }
}

document
  .querySelectorAll('.selling_plan_app_container')
  .forEach((sellingPlansWidgetContainer) => {
    new SellingPlansWidget(sellingPlansWidgetContainer);
  });
  1. Add the selling plan selector to sections/main-product.liquid, ideally before the quantity selector. Search for 'quantity_selector' to find the block and insert the snippet at the start

{% render 'selling-plans-integration', product: product, section: section %}

Now you should have a working selling plan selector on your product page.

Customizations for using SEPA based payment methods during checkout

To offer SEPA payment methods during checkout, add a SEPA checkout button to your cart.

You can skip this section if SEPA payment methods aren't needed.

  1. Create snippets/sepa-checkout-button.liquid to define the button template for the SEPA checkout button.

snippets/sepa-checkout-button.liquid
<button
  class="button button--full-width fh-sepa-checkout"
  onclick="fhSepaCheckout()"
>
  Check out with SEPA
</button>
  1. Create /assets/sepa-checkout.js. This file contains the logic for the SEPA checkout button.

/assets/sepa-checkout.js
async function initializeSEPACheckout() {
    if(window._sepa_checkout_initialized) {
      return;
    }
    window._sepa_checkout_initialized = true;
    const apiBaseUrl = window.Shopify.routes.root;
    let cachedCart = null;
    const getSellingPlanId = (shopifyId) => shopifyId.split("/").pop();
    const sepaToNative = Object.fromEntries(
      window.sellingPlanMapping.map((m) => [
        getSellingPlanId(m["sepa"]),
        getSellingPlanId(m["native"])
      ])
    );
   
    const getCart = async () => cachedCart ?? (cachedCart = (await fetchCart()));
    const fetchCart = async () => (await fetch(`${apiBaseUrl}cart.js`)).json();
    const updateCart = async (payload) => (await fetch(`${apiBaseUrl}cart/update.js`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    })).json();
    const clearCart = async () => fetch(`${apiBaseUrl}cart/clear.js`, { method: "POST" });
    const addToCart = async (payload) => (await fetch(`${apiBaseUrl}cart/add.js`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    })).json();
  
    const updateCheckoutButtonDisabledState = (disabled) => {
      document.querySelectorAll("button.fh-sepa-checkout, shopify-buy-it-now-button>button, button[name='checkout']").forEach((button) => {
        button.disabled = disabled;
      });
    };
    const replacePlans = async (planMapping) => {
      const cart = await fetchCart();
      if (!cart.items.some(item => item.selling_plan_allocation && planMapping[item.selling_plan_allocation.selling_plan.id])) {
        return false
      }
      const items = cart.items.map((item) => ({
        id: item.variant_id,
        quantity: item.quantity,
        ...(item.selling_plan_allocation
          ? {
            selling_plan:
              planMapping[item.selling_plan_allocation.selling_plan.id] ??
              item.selling_plan_allocation.selling_plan.id,
          }
          : {}),
        properties: item.properties,
      }));
      await clearCart();
      await addToCart({ items, attributes: cart.attributes });
      return true
    };
  
    const revertSEPAPlans = async () => {
      const cart = await getCart();
      if (!cart.items.some(item => item.selling_plan_allocation && sepaToNative[item.selling_plan_allocation.selling_plan.id])) {
        return
      }
      updateCheckoutButtonDisabledState(true);
      if (await replacePlans(sepaToNative)) {
        window.location.reload();
      } else {
        updateCheckoutButtonDisabledState(false);
      }
    };
  
  
    const observeCartChanges = () => {
      new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          if((entry.initiatorType === "xmlhttprequest" || entry.initiatorType === "fetch") && entry.name.includes("\/cart\/")) {
            cachedCart = null;
            checkCart();
          }
        })
      }).observe({ entryTypes: ["resource"] });
    }
  
    const checkCart = async () => {
      const cart = await getCart();
      await revertSEPAPlans();
    }
  
    checkCart();
    observeCartChanges();
    const planMapping = Object.fromEntries(
      window.sellingPlanMapping.map((m) => [
        getSellingPlanId(m["native"]),
        getSellingPlanId(m["sepa"])
      ])
    );
    const getCartInputs = () => {
      const attributes = document.querySelectorAll("input[name^='attributes['], textarea[name^='attributes[']");
      const note = document.querySelector("textarea[name='note'], input[name='note']");
      const payload = {}
      if(note) {
        payload.note = note.value;
      }
      if(attributes) {
        payload.attributes = Object.fromEntries(Array.from(attributes).map((a) => [a.name.replace("attributes[", "").replace("]", ""), a.value]))
      }
      return payload;
    }
    
    const handleClick = async () => {
      updateCheckoutButtonDisabledState(true);
      await updateCart(getCartInputs());
      await replacePlans(planMapping);
    
      window.location.href = "/checkout";
    };
    window.fhSepaCheckout = handleClick;
}
  
  window.addEventListener("pageshow", initializeSEPACheckout);
  1. Add sepa checkout buttons on where you want them to appear. By default the checkout buttons will be visible on sections/main-cart-footer.liquid, cart drawer and snippets/cart-drawer.liquid.

{% render 'sepa-checkout-button' %}
  1. Update layout/theme.liquid to include the SEPA checkout script and make the selling plan mapping available to the script. A good place to add this is right before the </head> tag.

{% assign firmhouse_app_id = '4631435' %}
{% assign app_name = 'app--' | append: firmhouse_app_id | append: '--firmhouse' %}
{% if shop.metaobjects[app_name].subscriptions.selling_plan_mapping %}
  <script>
    window.sellingPlanMapping = {{ shop.metaobjects[app_name].subscriptions.selling_plan_mapping }}
  </script>
{% endif %}
<script src="{{ 'sepa-checkout.js' | asset_url }}" defer="defer"></script>

Great job! You've successfully added Selling Plan options to your Shopify store. Customers can now subscribe to your products directly from the product page enhancing their shopping experience

setup guide
Dawn theme v15
Selling plan selector on product page
sections/main-product.liquid
sections/main-cart-footer.liquid
snippets/cart-drawer.liquid
snippets/cart-notification.liquid
layout/theme.liquid