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
Last updated
Was this helpful?
Was this helpful?
{% 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 %}
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);
});{% render 'selling-plans-integration', product: product, section: section %}