Adding cursor pagination to Novum
This guide will walk you through adding cursor pagination in Novum.
Instead of relying on session storage for product information for actions like adding, swapping, updating or updating product and rendering upsells, the new code fetches products when needed.
In the add/swap product flow, pagination and product search will be dynamic, so you always have the latest product information. This is especially beneficial for stores with a large number of products. Each section is separated by file. In each section, the old code is displayed, followed by an example of the updated code.
Platform:
- Shopify Checkout Integration
- Recharge Checkout on Shopify
- BigCommerce Integration
- Recharge Checkout on BigCommerce
Before you start
- Pagination is available natively on theme engine version 3.0.0. Upgrade the customer portal to the latest version to take advantage of the latest bug fixes and updates. See the Recharge changelog for more details.
- You must have a Recharge Pro account to implement this solution.
- If you’re using a version of Novum made before July 12th, 2021 and have made prior customizations, you will need to update your code manually.
- Follow the instructions in Customizing the Novum customer portal theme to access the theme code.
_edit-subscription.js
In the function swapProductHandler, is not relying on session storage to get information about the product, but dynamically retrieving it. It also sets some properties for pagination.
Old code
function swapProductHandler(event, source = "cancellation-flow") {
event.preventDefault();
ReCharge.Novum.backBtn.setAttribute("style", "visibility: visible");
// remove event where backBtn would lead to swap search that was placed in function swapProductDetailsHandler
ReCharge.Novum.backBtn.removeEventListener("click", swapProductHandler, false);
ReCharge.Novum.backBtn.removeEventListener("click", cancelSubscriptionFlow, false);
// add event where backBtn points to edit product
if (source === "cancellation-flow") {
ReCharge.Novum.backBtn.addEventListener("click", cancelSubscriptionFlow);
} else {
ReCharge.Novum.backBtn.addEventListener("click", editProduct);
}
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'cp_select_product' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_render_products.html' %}`;
let products = JSON.parse(sessionStorage.getItem("rc_products"));
let firstPageProducts = [...products].filter(prod =>
prod.subscription_defaults &&
prod.subscription_defaults.charge_interval_frequency == prod.subscription_defaults.order_interval_frequency_options[0]
);
ReCharge.Novum.Helpers.renderProducts(firstPageProducts.slice(0, 6), "swap");
const input = document.getElementById("rc_search");
input.setAttribute("placeholder", `{{ 'cp_search_product_to_swap' | t }}`);
input.addEventListener("keyup", (evt) => ReCharge.Novum.Helpers.searchProductsHandler(evt, 'swap'));
}
New code
async function swapProductHandler(event, source = "cancellation-flow") {
event.preventDefault();
ReCharge.Novum.backBtn.setAttribute("style", "visibility: visible");
// remove event where backBtn would lead to swap search that was placed in function swapProductDetailsHandler
ReCharge.Novum.backBtn.removeEventListener("click", swapProductHandler, false);
ReCharge.Novum.backBtn.removeEventListener("click", cancelSubscriptionFlow, false);
// add event where backBtn points to edit product
if (source === "cancellation-flow") {
ReCharge.Novum.backBtn.addEventListener("click", cancelSubscriptionFlow);
} else {
ReCharge.Novum.backBtn.addEventListener("click", editProduct);
}
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'cp_select_product' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_render_products.html' %}`;
const schema = ReCharge.Schemas.products.search('', 6, 1, true);
const data = await ReCharge.Actions.getProducts(6, schema);
let productsToRender = ReCharge.Novum.Utils.isOnetimesEnabled(data.products);
productsToRender = ReCharge.Novum.Utils.isPrepaidProduct(productsToRender);
ReCharge.Novum.Pagination.currentAddPage = 1;
ReCharge.Novum.Pagination.type = 'add';
ReCharge.Novum.Helpers.renderProducts(productsToRender, 'swap');
ReCharge.Novum.isSwap = true;
const input = document.getElementById("rc_search");
input.setAttribute("placeholder", `{{ 'cp_search_product_to_swap' | t }}`);
input.addEventListener("keyup", (evt) => ReCharge.Novum.Helpers.searchProductsHandler(evt, 'swap'));
}
The function swapProductDetailsHandler dynamically fetches a specific product and displays its information.
Old code
function swapProductDetailsHandler(event) {
event.preventDefault();
ReCharge.Novum.backBtn.addEventListener("click", swapProductHandler);
const subscription = ReCharge.Novum.subscription;
let products = JSON.parse(sessionStorage.getItem("rc_products"));
const productId = event.target.dataset.productId;
const productToSwap = products.find(
product => product.shopify_details.shopify_id == productId
);
const { shopify_id } = productToSwap.shopify_details.variants[0];
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'title_swap_product' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_swap_product_details.html' %}`;
const productContainer = document.querySelector(
".rc_swap_product_details_container"
);
let actionUrl = ReCharge.Endpoints.swap_subscription_url(subscription.id);
productContainer.setAttribute("action", actionUrl);
let redirect_url = ReCharge.Endpoints.update_subscription_url(subscription.id);
productContainer.innerHTML = ` <input type="hidden" name="redirect_url" value="${redirect_url}">
${
ReCharge.Novum.store.external_platform === 'big_commerce'
?`<input type="hidden" name="external_product_id" value="${productToSwap.shopify_product_id}">\`` :`
}
<input type="hidden" name="shopify_variant_id" value="${shopify_id}">
${ReCharge.Novum.Helpers.renderSubscriptionProductInfo(
productToSwap,
ReCharge.Novum.Helpers.getDisplayPrice(productToSwap)
)}
${ReCharge.Novum.Helpers.renderDeliveryOptions(productToSwap)}
<div id="product_variant_container">
<p class="text-font-14">{{ 'cp_variants' | t }}</p>
<ul id="product_options_container"></ul>
</div>
<button
type="submit"
class="rc_btn text-uppercase title-bold"
>
{{ 'button_swap_product' | t }}
</button>
`;
ReCharge.Novum.Helpers.renderVariants(productToSwap);
// Trigger the variant change callback to ensure correct price display
ReCharge.Novum.Helpers.triggerVariantUpdate();
// Add handler for subscription/otp creation
document
.querySelector('#subscriptionSwapForm')
.addEventListener(
'submit',
(e) => ReCharge.Novum.Utils.createProduct(e, productToSwap.shopify_details.shopify_id, 'swap')
);
}
New code
async function swapProductDetailsHandler(event)
{
event.preventDefault();
ReCharge.Novum.backBtn.addEventListener("click", swapProductHandler);
const subscription = ReCharge.Novum.subscription;
const productId = event.target.dataset.productId;
const schema = ReCharge.Schemas.products.getProduct(productId);
const data = await ReCharge.Actions.getProducts(6, schema);
const productToSwap = data.products[0];
const
{
shopify_id
} = productToSwap.shopify_details.variants[0];
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'title_swap_product' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_swap_product_details.html' %}`;
const productContainer = document.querySelector(
".rc_swap_product_details_container"
);
let actionUrl = ReCharge.Endpoints.swap_subscription_url(subscription.id);
productContainer.setAttribute("action", actionUrl);
let redirect_url = ReCharge.Endpoints.update_subscription_url(subscription.id);
productContainer.innerHTML = ` <input type="hidden" name="redirect_url" value="${redirect_url}">
${
ReCharge.Novum.store.external_platform === 'big_commerce'
?`<input type="hidden" name="external_product_id" value="${productToSwap.shopify_product_id}">\`
: \`\`
}
<input type="hidden" name="shopify_variant_id" value="${shopify_id}">
${ReCharge.Novum.Helpers.renderSubscriptionProductInfo(
productToSwap,
ReCharge.Novum.Helpers.get@@DisplayPrice(productToSwap)
)}
${ReCharge.Novum.Helpers.renderDeliveryOptions(productToSwap)}
<div id="product_variant_container">
```
<p class="text-font-14">{{ 'cp_variants' | t }}</p>
<ul id="product_options_container"></ul>
</div>
<button
type="submit"
class="rc_btn text-uppercase title-bold"
>
{{ 'button_swap_product' | t }}
</button>
`;
ReCharge.Novum.Helpers.renderVariants(productToSwap);
// Trigger the variant change callback to ensure correct price display
ReCharge.Novum.Helpers.triggerVariantUpdate();
// Add handler for subscription/otp creation
document
.querySelector('#subscriptionSwapForm')
.addEventListener(
'submit',
(e) => ReCharge.Novum.Utils.createProduct(
e,
productToSwap.shopify_details.shopify_id,
'swap',
productToSwap.shopify_details.variants
)
);
}
return productsToRender;
}
return productsToRender;
}
_helpers.js
The function updateVariant filters products fetched asynchronously, instead of relying on session storage.
Replace this line of code:
const product = products.find(
prod => prod.shopify_details.shopify_id == productId
);
With this:
const product = ReCharge.Novum.products.find(
prod => prod.shopify_details.shopify_id == productId
);
The method searchProductsHandler, refactors this code section and uses ReCharge.Schemas, so schemas are centralized and easier to modify. Pagination is also updated.
Old code
searchProductsHandler: async function(evt, action = 'add') {
if (evt.keyCode === 13) {
evt.preventDefault();
const baseSource = {{ settings | json }}.customer_portal.onetime.enabled
? `"base_source": "shopify"`
: `"base_source": "store_settings"`;
const productsContainer = document.querySelector('.rc_product_list_container');
const searchValue = evt.target.value;
const schema = `{ "products": { "title": "${searchValue.toLowerCase()}", ${baseSource} } }`;
try {
const url = `${ReCharge.Endpoints.request_objects()}&schema=${schema}`;
const response = await axios(url);
ReCharge.Novum.Helpers.validateResponseData(response.data, 'products');
let products = response.data.products;
if (!Array.isArray(response.data.products)) {
products = [response.data.products];
}
if (action === 'swap') {
products = products.filter(prod => {
if (prod.subscription_defaults) {
const {
charge_interval_frequency,
order_interval_frequency_options
} = prod.subscription_defaults;
if (
order_interval_frequency_options.length === 1 &&
Number(order_interval_frequency_options[0]) !== charge_interval_frequency
) {
return;
}
return prod;
}
});
}
if (
products &&
products.length
) {
productsContainer.innerHTML = ``;
ReCharge.Novum.Helpers.renderProducts(products, action);
} else {
productsContainer.innerHTML = `{{ "cp_no_products_found" | t }}`;
}
} catch (error) {
console.error(error);
productsContainer.innerHTML = `{{ "cp_no_products_found" | t }}`;
}
}
}
New code
searchProductsHandler: async function(ev, action = 'add') {
if (ev.keyCode === 13) {
ev.preventDefault();
const searchQuery = ev.target.value
.trim()
.toLowerCase()
.replace(/"/g, '')
;
const isSwap = action === 'add'
? false
: true
;
const schema = ReCharge.Schemas.products.search(searchQuery, 6, 1, isSwap);
const data = await ReCharge.Actions.getProducts(6, schema);
// Remove OTPs for Swap feature
let productsToRender = ReCharge.Novum.Utils.isOnetimesEnabled(data.products);
if (isSwap) {
productsToRender = ReCharge.Novum.Utils.isPrepaidProduct(productsToRender);
}
ReCharge.Novum.Helpers.renderProducts(productsToRender, action);
ReCharge.Novum.Pagination.updatePagination();
}
}
The method renderUpsells was converted to an asynchronous function and fetches products instead of relying on session storage. Pagination has been initialized.
Old code
renderUpsells: function(type = 'upsell', products, currentPage = 1, productsPerPage = 12) {
const container = document.querySelector('#rc\_\_upsells--container');
// Hide loader
let loader = document.querySelector('#upsells--loader');
if (loader) {
loader.setAttribute('style', 'display: none;');
}
let productCards = '';
let productsToRender = ReCharge.Novum.Utils.isOnetimesEnabled(products);
ReCharge.Novum.Helpers.renderPagination(productsToRender, currentPage, type, productsPerPage);
if (products.length) {
productCards = productsToRender
.slice(0, 12)
.map(product => {
let imageSrc = ReCharge.Novum.Utils.getImageUrl(product);
let price = ReCharge.Novum.Helpers.getDisplayPrice(product);
const { shopify_id, handle, title } = product.shopify_details;
return `
<li class="js-toggle-card text-center rc_element_wrapper rc_single_product_card-wrapper" id="product_${shopify_id}">
<div class="js-card js-card-${shopify_id}">
<div class="rc_image_container">
<img src="${imageSrc}" alt="${handle}">
</div>
<p
class="text-font-14 title-bold upsells-title ${title.replace('Auto renew', '').trim().length > 45 ? 'upsell_text--clip' : 'upsell_text--center'}"
>
${title.replace('Auto renew', '')}
</p>
<p>
${ReCharge.Novum.Utils.getCurrency()}${Number(price).toFixed(2)}
</p>
<button
class="rc_btn--secondary text-uppercase title-bold upsell-btn-mobile"
data-product-id="${shopify_id}"
onclick="ReCharge.Novum.Helpers.toggleUpsellsButtons(event)"
>
{{ 'cp_add_render_upsells' | t }}
</button>
</div>
${ReCharge.Novum.Helpers.renderUpsellButtons(product)}
</li>
`;
}).join('');
}
if (container) {
container.innerHTML = `${productCards}`;
}
}
New code
renderUpsells: async function(products) {
const container = document.querySelector('#rc\_\_upsells--container');
// Hide loader
let loader = document.querySelector('#upsells--loader');
if (loader) {
loader.setAttribute('style', 'display: none;');
}
let productCards = '';
let productsToRender = ReCharge.Novum.Utils.isOnetimesEnabled(products);
if (productsToRender.length) {
productCards = productsToRender
.slice(0, 12)
.map(product => {
let imageSrc = ReCharge.Novum.Utils.getImageUrl(product);
let price = ReCharge.Novum.Helpers.getDisplayPrice(product);
const { shopify_id, handle, title } = product.shopify_details;
return `
<li class="js-toggle-card text-center rc_element_wrapper rc_single_product_card-wrapper" id="product_${shopify_id}">
<div class="js-card js-card-${shopify_id}">
<div class="rc_image_container">
<img src="${imageSrc}" alt="${handle}">
</div>
<p
class="text-font-14 title-bold upsells-title ${title.replace('Auto renew', '').trim().length > 45 ? 'upsell_text--clip' : 'upsell_text--center'}"
>
${title.replace('Auto renew', '')}
</p>
<p>
${ReCharge.Novum.Utils.getCurrency()}${Number(price).toFixed(2)}
</p>
<button
class="rc_btn--secondary text-uppercase title-bold upsell-btn-mobile"
data-product-id="${shopify_id}"
onclick="ReCharge.Novum.Helpers.toggleUpsellsButtons(event)"
>
{{ 'cp_add_render_upsells' | t }}
</button>
</div>
${ReCharge.Novum.Helpers.renderUpsellButtons(product)}
</li>
`;
}).join('');
}
if (container) {
container.innerHTML = `${productCards}`;
ReCharge.Novum.Pagination.renderInitialPagination();
}
}
We converted the method addUpsellHandler to an asynchronous function so it retrieves the specific product that will be added.
Old code
addUpsellHandler: function(evt) {
evt.preventDefault();
const chosenOption = evt.target.value;
const parentElement = evt.target.closest('.rc_upsells-btns');
const productId = parentElement.dataset.productId;
const products = JSON.parse(sessionStorage.getItem('rc_products'));
const settings = {{ settings | json }};
const chosenProduct = products.find(product => product.shopify_details.shopify_id == productId);
const subscription = ReCharge.Novum.subscription;
let url = chosenOption.includes('one-time') ? "{{ onetime_list_url }}" : "{{ subscription_list_url }}";
let isInStock;
if (chosenProduct.shopify_details.variants.length > 1) {
ReCharge.Novum.sidebarHeading.innerHTML = `{{ "cp_add_product_label" | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_add_product_details.html' %}`;
let productContainer = document.querySelector('.rc_add_product_details_container');
productContainer.innerHTML += `
<input type="hidden" name="shopify_variant_id" value="${chosenProduct.shopify_details.variants[0].shopify_id}">
${ReCharge.Novum.store.external_platform === 'big_commerce'
? `<input type="hidden" name="external_product_id" value="${chosenProduct.shopify_details.shopify_id}">`
: ``
}
<input type="hidden" name="next_charge_scheduled_at" id="next_charge_scheduled_at" value="${subscription.next_charge_scheduled_at}" required >
<input type="hidden" name="address_id" value="${subscription.address_id}" required>
<input type="hidden" name="redirect_url" value="{{ schedule_url }}">
${!chosenOption.includes('one-time') ? '' :
`<input type="hidden" name="properties[add_on]" value="True">
<input type="hidden" name="properties[add_on_subscription_id]" value="${subscription.id}">
`
}
${ReCharge.Novum.Helpers.renderSubscriptionProductInfo(
chosenProduct,
ReCharge.Novum.Helpers.getDisplayPrice(chosenProduct)
)}
${chosenOption.includes('one-time') ? '' :
`<div id="product_schedule_container">
${ReCharge.Novum.Helpers.renderDeliveryOptions(chosenProduct)}
</div>`
}
<div id="product_variant_container">
<p class="text-font-14">{{ 'cp_variants' | t }}</p>
<ul id="product_options_container"></ul>
</div>
<button type="submit" class="rc_btn text-uppercase title-bold">
${evt.target.value}
</button>
`;
ReCharge.Novum.Helpers.renderVariants(chosenProduct);
document.querySelector('#subscriptionNewForm').setAttribute('action', url);
// Trigger the variant change callback to ensure correct price display
ReCharge.Novum.Helpers.triggerVariantUpdate();
// Add handler for subscription/otp creation
document
.querySelector('#subscriptionNewForm')
.addEventListener(
'submit',
(e) => ReCharge.Novum.Utils.createProduct(e, chosenProduct.shopify_details.shopify_id)
);
ReCharge.Novum.toggleSidebar();
} else {
isInStock = ReCharge.Novum.Utils.checkInventory(chosenProduct.shopify_details.variants[0]);
if (isInStock) {
evt.target.value = `{{ 'cp_processing_message' | t }}`;
evt.target.disabled = true;
let postUrl = 'create_onetime';
const data = {
address_id: subscription.address_id,
external_product_id: chosenProduct.shopify_details.shopify_id,
shopify_variant_id: chosenProduct.shopify_details.variants[0].shopify_id,
quantity: 1,
next_charge_scheduled_at: subscription.next_charge_scheduled_at,
"properties[add_on]": true,
"properties[add_on_subscription_id]": subscription.id,
if (chosenOption.includes('subscription')) {
postUrl = 'list_subscriptions_url';
data.order_interval_frequency = chosenProduct.subscription_defaults.order_interval_frequency_options[0];
data.charge_interval_frequency = chosenProduct.subscription_defaults.order_interval_frequency_options.length > 1
? chosenProduct.subscription_defaults.order_interval_frequency_options[0]
: chosenProduct.subscription_defaults.charge_interval_frequency
;
data.order_interval_unit = chosenProduct.subscription_defaults.order_interval_unit;
}
data.redirect_url = "{{ schedule_url }}";
ReCharge.Actions.put(postUrl, null, data, chosenProduct.title);
} else {
evt.target.value = `{{ 'cp_out_of_stock' | t }}`;
evt.target.disabled = true;
ReCharge.Toast.addToast(`{{ 'cp_toast_error' | t }}`, `{{ 'cp_product_out_of_stock' | t }}`);
}
}
}
}
New code
addUpsellHandler: async function(evt) {
evt.preventDefault();
const chosenOption = evt.target.value;
const parentElement = evt.target.closest('.rc_upsells-btns');
const productId = parentElement.dataset.productId;
const settings = {{ settings | json }};
const schema = ReCharge.Schemas.products.getProduct(productId);
const data = await ReCharge.Actions.getProducts(6, schema);
const chosenProduct = data.products[0];
const subscription = ReCharge.Novum.subscription;
let url = chosenOption.includes('one-time') ? "{{ onetime_list_url }}" : "{{ subscription_list_url }}";
let isInStock;
if (chosenProduct.shopify_details.variants.length > 1) {
ReCharge.Novum.sidebarHeading.innerHTML = `{{ "cp_add_product_label" | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_add_product_details.html' %}`;
let productContainer = document.querySelector('.rc_add_product_details_container');
productContainer.innerHTML += `
<input type="hidden" name="shopify_variant_id" value="${chosenProduct.shopify_details.variants[0].shopify_id}">
${ReCharge.Novum.store.external_platform === 'big_commerce'
? `<input type="hidden" name="external_product_id" value="${chosenProduct.shopify_details.shopify_id}">`
: ``
}
<input type="hidden" name="next_charge_scheduled_at" id="next_charge_scheduled_at" value="${subscription.next_charge_scheduled_at}" required >
<input type="hidden" name="address_id" value="${subscription.address_id}" required>
<input type="hidden" name="redirect_url" value="{{ schedule_url }}">
${!chosenOption.includes('one-time') ? '' :
`<input type="hidden" name="properties[add_on]" value="True">
<input type="hidden" name="properties[add_on_subscription_id]" value="${subscription.id}">
`
}
${ReCharge.Novum.Helpers.renderSubscriptionProductInfo(
chosenProduct,
ReCharge.Novum.Helpers.getDisplayPrice(chosenProduct)
)}
${chosenOption.includes('one-time') ? '' :
`<div id="product_schedule_container">
${ReCharge.Novum.Helpers.renderDeliveryOptions(chosenProduct)}
</div>`
}
<div id="product_variant_container">
<p class="text-font-14">{{ 'cp_variants' | t }}</p>
<ul id="product_options_container"></ul>
</div>
<button type="submit" class="rc_btn text-uppercase title-bold">
${evt.target.value}
</button>
`;
ReCharge.Novum.Helpers.renderVariants(chosenProduct);
document.querySelector('#subscriptionNewForm').setAttribute('action', url);
// Trigger the variant change callback to ensure correct price display
ReCharge.Novum.Helpers.triggerVariantUpdate();
// Add handler for subscription/otp creation
document
.querySelector('#subscriptionNewForm')
.addEventListener(
'submit',
(e) => ReCharge.Novum.Utils.createProduct(
e,
chosenProduct.shopify_details.shopify_id,
'create',
chosenProduct.shopify_details.variants
)
);
ReCharge.Novum.toggleSidebar();
} else {
isInStock = ReCharge.Novum.Utils.checkInventory(chosenProduct.shopify_details.variants[0]);
if (isInStock) {
evt.target.value = `{{ 'cp_processing_message' | t }}`;
evt.target.disabled = true;
let postUrl = 'create_onetime';
const data = {
address_id: subscription.address_id,
external_product_id: chosenProduct.shopify_details.shopify_id,
shopify_variant_id: chosenProduct.shopify_details.variants[0].shopify_id,
quantity: 1,
next_charge_scheduled_at: subscription.next_charge_scheduled_at,
"properties[add_on]": true,
"properties[add_on_subscription_id]": subscription.id,
}
if (chosenOption.includes('subscription')) {
postUrl = 'list_subscriptions_url';
data.order_interval_frequency = chosenProduct.subscription_defaults.order_interval_frequency_options[0];
data.charge_interval_frequency = chosenProduct.subscription_defaults.order_interval_frequency_options.length > 1
? chosenProduct.subscription_defaults.order_interval_frequency_options[0]
: chosenProduct.subscription_defaults.charge_interval_frequency
;
data.order_interval_unit = chosenProduct.subscription_defaults.order_interval_unit;
}
data.redirect_url = "{{ schedule_url }}";
ReCharge.Actions.put(postUrl, null, data, chosenProduct.title);
} else {
evt.target.value = `{{ 'cp_out_of_stock' | t }}`;
evt.target.disabled = true;
ReCharge.Toast.addToast(`{{ 'cp_toast_error' | t }}`, `{{ 'cp_product_out_of_stock' | t }}`);
}
}
}
Method renderProducts was refactored and does not accept params currentPage and productsPerPage. Additional check for products was added. Pagination was initialized.
Old code
renderProducts: function(products, type, currentPage=1, productsPerPage=6) {
ReCharge.Novum.Helpers.renderPagination(products, currentPage, type, productsPerPage);
const productsContainer = document.querySelector('.rc_product_list_container');
products.forEach(product => {
let otpPrice = product.shopify_details.variants[0].price;
let subPrice = product.shopify_details.variants[0].price;
if (product.subscription_defaults) {
const hasDiscount = product.discount_amount && product.discount_amount !== 0 || false;
if (hasDiscount) {
if (product.discount_type == 'percentage') {
subPrice *= 1 - product.discount_amount / 100;
} else {
subPrice -= product.discount_amount;
}
}
}
const { title, shopify_id } = product.shopify_details;
productsContainer.innerHTML += `
<li class="rc_product_card border-light text-center rc_single_product_card-wrapper" id="product_${shopify_id}">
<div class="rc_image_container">
<img src="${ReCharge.Novum.Utils.getImageUrl(product)}" alt="${product.title}" class="rc_img__sidebar" height="100px" width="100px">
</div>
<p class="product-title title-bold text-font-14 ${title.trim().length > 35 ? 'upsell_text--clip' : 'upsell_text--center'}">
${title}
</p>
<p>
{% include '_onetime-icon.svg' %}
${ReCharge.Novum.Utils.getCurrency()}${Number(otpPrice).toFixed(2)}
<svg class="vertical-divider" width="1" height="9" fill="none"><path d="M.962 8.553H.234V.125h.728v8.428z" fill="var(--color-dark-green)"/></svg>
{% include '_subscription-icon.svg' %}
${ReCharge.Novum.Utils.getCurrency()}${Number(subPrice).toFixed(2)}
</p>
<button
class="rc_btn text-uppercase title-bold view-product-button"
data-product-id="${shopify_id}"
>
{{ 'cp_select_button' | t }}
</button>
</li>
`;
});
let handler = ReCharge.Novum.Utils.addProductDetailsHandler;
if (type === 'swap') {
handler = swapProductDetailsHandler;
}
document.querySelectorAll('.view-product-button')
.forEach(button => {
button.addEventListener('click', handler)
});
}
New code
renderProducts: function(products, type = 'add') {
const productsContainer = document.querySelector('.rc_product_list_container');
productsContainer.innerHTML = '';
if (!products.length) {
return productsContainer.innerHTML = `{{ "cp_no_products_found" | t }}`;
}
products.forEach(product => {
let otpPrice = product.shopify_details.variants[0].price;
let subPrice = product.shopify_details.variants[0].price;
if (product.subscription_defaults) {
const hasDiscount = product.discount_amount && product.discount_amount !== 0 || false;
if (hasDiscount) {
if (product.discount_type == 'percentage') {
subPrice *= 1 - product.discount_amount / 100;
} else {
subPrice -= product.discount_amount;
}
}
}
const { title, shopify_id } = product.shopify_details;
productsContainer.innerHTML += `
<li class="rc_product_card border-light text-center rc_single_product_card-wrapper" id="product_${shopify_id}">
<div class="rc_image_container">
<img src="${ReCharge.Novum.Utils.getImageUrl(product)}" alt="${product.title}" class="rc_img__sidebar" height="100px" width="100px">
</div>
<p class="product-title title-bold text-font-14 ${title.trim().length > 35 ? 'upsell_text--clip' : 'upsell_text--center'}">
${title}
</p>
<p>
{% include '_onetime-icon.svg' %}
${ReCharge.Novum.Utils.getCurrency()}${Number(otpPrice).toFixed(2)}
<svg class="vertical-divider" width="1" height="9" fill="none"><path d="M.962 8.553H.234V.125h.728v8.428z" fill="var(--color-dark-green)"/></svg>
{% include '_subscription-icon.svg' %}
${ReCharge.Novum.Utils.getCurrency()}${Number(subPrice).toFixed(2)}
</p>
<button
class="rc_btn text-uppercase title-bold view-product-button"
data-product-id="${shopify_id}"
{{ 'cp_select_button' | t }}
</button>
</li>
`;
});
let handler = ReCharge.Novum.Utils.addProductDetailsHandler;
if (type === 'swap') {
handler = swapProductDetailsHandler;
}
document.querySelectorAll('.view-product-button')
.forEach(button => {
button.addEventListener('click', handler)
})
;
ReCharge.Novum.Pagination.renderInitialPagination();
}
Methods renderPagination and goToPageHandler were deleted as they’re no longer needed in the new flow.
Recharge refactored fetchProductsRetentionStrategiesOrders method. Checks for external_platform and whether store allows for adding a product were deleted. Recharge also deleted the render Onetimes flow.
Remove the following code
if (store.external_platform === 'big_commerce') {
schema = `{ "products": { "limit": 250, "page": 1 }, "retention_strategies": { "sort_by":"id-asc" }, "orders": { "status": "SUCCESS" } }`;
}
// render Onetimes
let container = document.querySelector('#rc\_\_upsells--container');
const upsellWrapper = document.querySelector(".upsells--wrapper") || null;
if (container && mappedProducts.length > 0 && ReCharge.Novum.settings.customer_portal.subscription.add_product) {
ReCharge.Novum.Helpers.renderUpsells("upsell", mappedProducts);
} else {
if (upsellWrapper !== null) {
upsellWrapper.innerHTML = ReCharge.Utils.renderNoProductsLayout();
}
}
// Check if store allows adding product
if (settings.customer_portal.subscription.add_product) {
const addProductBtn = document.querySelector('.js-add-product-btn');
if (addProductBtn != null) {
ReCharge.Novum.Helpers.showElement(addProductBtn);
}
}
Method fetchProducts was refactored and check for external_platform has been deleted.
Remove the following code
if (store.external_platform === 'big_commerce') {
schema = `{ "products": { "limit": 250, "page": ${page} } }`;
}
_pagination.css
This is a new file. Styles for pagination were isolated into a separate file to make editing CSS easier.
- Create a new file named _pagination.css
- Paste the following code to the new file
body#recharge-novum #recharge-te .rct_pagination\_\_container {
display: flex;
justify-content: center;
align-items: center;
}
body#recharge-novum #recharge-te .rct_pagination**prev,
body#recharge-novum #recharge-te .rct_pagination**next {
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--primary-color);
padding: 10px;
height: 40px;
width: 40px;
border-radius: 50%;
font-size: 18px;
color: var(--primary-color);
}
body#recharge-novum #recharge-te .rct_pagination**prev:hover,
body#recharge-novum #recharge-te .rct_pagination**next:hover {
cursor: pointer;
}
body#recharge-novum #recharge-te .rct_pagination\_\_page {
margin: 0 6px;
padding: 8px;
}
body#recharge-novum #recharge-te .rct_pagination\_\_page--current {
font-weight: 700;
color: var(--primary-color);
margin: 0px 10px;
}
body#recharge-novum #recharge-te .rct_pagination\_\_container--hidden {
display: none;
}
body#recharge-novum #recharge-te .rct_pagination**prev--disabled,
body#recharge-novum #recharge-te .rct_pagination**next--disabled {
opacity: 0.5;
pointer-events: none;
}
_pagination.html
We created a new HTML file to facilitate pagination.
- Create a file named _pagination.html
- Paste the following code in the new file
<style>
{% include '_pagination.css' %}
</style>
<div class="rct_pagination__container rct_pagination__container--add rct_pagination__container--hidden">
<span
class="rct_pagination__prev rct_pagination__prev--add rct_pagination__prev--disabled"
onclick="ReCharge.Novum.Pagination.previousPageHandler(event)"
data-handler-type="add"
>
<i class="fas fa-chevron-left"></i>
</span>
<span
class="rct_pagination__page rct_pagination__current--add rct_pagination__page--current"
data-pagination-current-page
data-handler-type="add"
>
1
</span>
<span
class="rct_pagination__next rct_pagination__next--add "
onclick="ReCharge.Novum.Pagination.nextPageHandler(event)"
data-handler-type="add"
>
<i class="fas fa-chevron-right"></i>
</span>
</div>
_recharge.js
Check for adding a product has been deleted. Initial pagination setup has been added.
Remove the following code
// Check if store allows adding a product
if(settingsStore.customer_portal.subscription.add_product) {
const addProductBtn = document.querySelector('.js-add-product-btn');
const rc_products = JSON.parse(sessionStorage.getItem('rc_products')) || null;
if (addProductBtn && rc_products && rc_products.length) {
ReCharge.Novum.Helpers.showElement(addProductBtn);
}
}
Inside ReCharge.Novum.toggleSidebar, at the very end, add the following code:
if (ReCharge.Novum.Pagination.type === 'upsell') {
ReCharge.Novum.Pagination.updateBtnProps('container');
ReCharge.Novum.Pagination.updateBtnProps('prev');
ReCharge.Novum.Pagination.updateBtnProps('next');
ReCharge.Novum.Pagination.updateBtnProps('current');
ReCharge.Novum.Pagination.limit = 12;
}
_render_products.html
Instead of an HTML unordered list, include this snippet for pagination HTML structure.
Replace the following code
<ul class="pagination_buttons_container">
</ul>
With the following code
{% include '\_pagination.html' %}
_scripts.js
We've added a new method to ReCharge.Actions called getProducts. It is responsible for fetching products whenever needed. Users will always have the latest version of a product using this asynchronous function.
Add the following code to ReCharge.Actions
getProducts: async function(limit = 6, productsSchema = null, url = null, isSwap = false) {
const schema = productsSchema || ReCharge.Schemas.products.list(limit);
let dataUrl = attachQueryParams(`
${ReCharge.Endpoints.request_objects()}&schema=${schema}`
);
ReCharge.Novum.Pagination.limit = limit;
if (url) {
dataUrl = url;
if (isSwap) {
dataUrl = url.replace(/%22base_source%22%3A%20%22store_settings%22%2C%20/, '%20%22exclude_prepaids%22:%20true%20,');
}
}
try {
const response = await axios(dataUrl);
ReCharge.Novum.products = response.data.products;
if (
response.data.meta &&
response.data.meta.products
) {
ReCharge.Novum.meta = response.data.meta.products;
if (limit === 12) {
ReCharge.Novum.upsellMeta = response.data.meta.products;
ReCharge.Novum.Pagination.limit = 12;
} else if (limit === 6) {
ReCharge.Novum.addMeta = response.data.meta.products;
ReCharge.Novum.Pagination.limit = 6;
}
}
if (
response.data.products &&
!Array.isArray(response.data.products)
) {
ReCharge.Novum.products = [response.data.products];
response.data.products = [response.data.products];
}
return response.data;
} catch(error) {
console.error(error);
} finally {
delete window.locked;
}
}
New schemas have also been added to the products object in ReCharge.Schemas. These schemas are now centralized and easier to modify.
Add the following code to ReCharge.Schemas
products: {
list(limit = 6, page = 1) {
return `{ "products": { "base_source": "store_settings", "limit": ${limit}, "page": ${page} } }`;
},
search(query, limit = 6, page = 1, isSwap = false) {
let title = \``;
let excludePrepaids = `, "exclude_prepaids": true`;
let productTypes = `, "product_types": "subscription"\`;
if (query.length > 0) {
title = `"title": "${query}",`;
}
if (!isSwap) {
excludePrepaids = ``;
productTypes = ``;
}
return `{ "products": { ${title} "base_source": "store_settings", "limit": ${limit}, "page": ${page} ${excludePrepaids} ${productTypes} } }`;
},
getProduct(id) {
return `{ "products": { "shopify_product_id": ${id} } }`;
}
}
subscriptions.js
We've converted the function addProductHandler into an asynchronous function so it can dynamically fetch products, instead of relying on filtering products from session storage. These products are rendered in the sidebar. Pagination has been initialized.
Old code
// Open modal to show all products
function addProductHandler(evt) {
evt.preventDefault();
// Hide back button
ReCharge.Novum.backBtn.setAttribute('style', 'visibility: hidden');
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'cp_select_product' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_render_products.html' %}`;
let products = JSON.parse(sessionStorage.getItem('rc_products'));
let productsToRender = ReCharge.Novum.Utils.isOnetimesEnabled(products);
ReCharge.Novum.Helpers.renderProducts(productsToRender.slice(0, 6), 'add');
let input = document.getElementById('rc_search');
input.addEventListener('keyup', ReCharge.Novum.Helpers.searchProductsHandler);
ReCharge.Novum.toggleSidebar();
}
New code
// Open modal to show all products
async function addProductHandler(evt) {
evt.preventDefault();
// Hide back button
ReCharge.Novum.backBtn.setAttribute('style', 'visibility: hidden');
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'cp_select_product' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_render_products.html' %}`;
const data = await ReCharge.Actions.getProducts(6);
const productsToRender = ReCharge.Novum.Utils.isOnetimesEnabled(data.products);
ReCharge.Novum.Pagination.currentAddPage = 1;
ReCharge.Novum.Pagination.limit = 6;
ReCharge.Novum.Pagination.type = 'add';
ReCharge.Novum.Helpers.renderProducts(productsToRender, 'add');
let input = document.getElementById('rc_search');
input.addEventListener('keyup', ReCharge.Novum.Helpers.searchProductsHandler);
ReCharge.Novum.toggleSidebar();
}
Function renderAddProductDetails has been slightly modified with an additional argument passed to the createProduct function.
Old code
// Add handler for subscription/otp creation
document
.querySelector('#subscriptionNewForm')
.addEventListener(
'submit',
(e) => ReCharge.Novum.Utils.createProduct(e, product.shopify_details.shopify_id)
);
New code
// Add handler for subscription/otp creation
document
.querySelector('#subscriptionNewForm')
.addEventListener(
'submit',
(e) => ReCharge.Novum.Utils.createProduct(
e,
product.shopify_details.shopify_id,
'create',
product.shopify_details.variants
)
);
utils.js
The method isOnetimesEnabled
has been modified slightly.
Old code
isOnetimesEnabled: function(products) {
let productsToRender;
const storeSettings = {{ settings | json }};
if (storeSettings.customer_portal.onetime.enabled) {
productsToRender = [...products];
} else {
productsToRender = [...products].filter(
prod => prod.subscription_defaults && prod.subscription_defaults.storefront_purchase_options !== 'onetime_only'
);
}
return productsToRender;
}
New code
isOnetimesEnabled: function (products)
{
let productsToRender;
const storeSettings =
{{ settings | json }};
if (storeSettings.customer_portal.onetime.enabled)
{
productsToRender = products;
}
else
{
productsToRender = products.filter(
prod => prod.subscription_defaults && prod.subscription_defaults.storefront_purchase_options !== 'onetime_only'
);
}
return productsToRender;
}
A new method was added to ReCharge.Novum.Utils named isPrepaidProduct . This new method is responsible for checking if a product is sold as a prepaid or not.
New code
isPrepaidProduct: function (products)
{
return products.filter(prod =>
{
if (prod.subscription_defaults)
{
if (prod.subscription_defaults.order_interval_frequency_options.length === 1)
{
return prod.subscription_defaults.charge_interval_frequency === Number(prod.subscription_defaults.order_interval_frequency_options[0])
}
return products;
} })
}
Method addProductDetailsHandler was converted to an asynchronous function. It fetches the specific product, instead of relying on filtering products from session storage.
Old code
addProductDetailsHandler: function (evt)
{
evt.preventDefault();
const productId = evt.target.dataset.productId;
let product = JSON.parse(sessionStorage.getItem("rc_products")).find(
prod => prod.shopify_details.shopify_id == productId
);
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'cp_edit_details' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_add_product_details.html' %}`;
renderAddProductDetails(product);
}
New code
addProductDetailsHandler: async function (ev)
{
ev.preventDefault();
const productId = ev.target.dataset.productId;
const schema = ReCharge.Schemas.products.getProduct(productId);
const data = await ReCharge.Actions.getProducts(6, schema);
ReCharge.Novum.sidebarHeading.innerHTML = `{{ 'cp_edit_details' | t }}`;
ReCharge.Novum.sidebarContent.innerHTML = `{% include '_add_product_details.html' %}`;
renderAddProductDetails(data.products[0]);
}
A new parameter has been added to the method createProduct named variants .
Old code
createProduct: async function(evt, shopifyId, message = 'create') {
New code
createProduct: async function(evt, shopifyId, message = 'create', variants) {
Code for filtering products from session storage has been deleted.
Remove the following code:
const products = JSON.parse(sessionStorage.getItem("rc_products"));
const chosenProduct = products.find(
prod => prod.shopify_details.shopify_id === shopifyId
);
Remove the following code:
const chosenVariant = chosenProduct.shopify_details.variants.find(
variant => variant.shopify_id == data["shopify_variant_id"]
);
Replace it with:
const chosenVariant = variants.find(
variant => variant.shopify_id == data["shopify_variant_id"]
);
We added a new method called ReCharge.Novum.Pagination.
We added a new method called ReCharge.Novum.Pagination.
Add the following code in _utils.js file:
ReCharge.Novum.Pagination = {
currentUpsellPage: 1,
currentAddPage: 1,
type: 'add',
limit: 6,
hasPrevMeta: function(type) {
if (type === 'add') {
return (
ReCharge.Novum.addMeta &&
ReCharge.Novum.addMeta.previous
)
}
return (
ReCharge.Novum.upsellMeta &&
ReCharge.Novum.upsellMeta.previous
)
},
hasNextMeta: function(type) {
if (type === 'add') {
return (
ReCharge.Novum.addMeta &&
ReCharge.Novum.addMeta.next
)
}
return (
ReCharge.Novum.upsellMeta &&
ReCharge.Novum.upsellMeta.next
)
},
previousPageHandler: function(ev) {
const handlerType = ev.target.closest('[data-handler-type]').dataset.handlerType;
if (this.hasPrevMeta(handlerType)) {
if (handlerType === 'add') {
url = ReCharge.Novum.addMeta.previous;
this.currentAddPage > 1
? this.currentAddPage -= 1
: ''
;
} else {
url = ReCharge.Novum.upsellMeta.previous;
this.currentUpsellPage > 1
? this.currentUpsellPage -= 1
: ''
;
}
this.goToPageHandler(
handlerType,
ev,
url
);
}
},
nextPageHandler: function(ev) {
const handlerType = ev.target.closest('[data-handler-type]').dataset.handlerType;
if (this.hasNextMeta(handlerType)) {
let url = '';
if (handlerType === 'add') {
url = ReCharge.Novum.addMeta.next;
this.currentAddPage += 1;
} else {
url = ReCharge.Novum.upsellMeta.next;
this.currentUpsellPage += 1;
}
this.goToPageHandler(
handlerType,
ev,
url
);
}
},
goToPageHandler: async function(handlerType, ev, url) {
this.disableButtons(handlerType);
this.type = handlerType;
if (handlerType === 'upsell') {
const data = await ReCharge.Actions.getProducts(12, null, url);
ReCharge.Novum.Helpers.renderUpsells(data.products);
} else {
let isSwap = false;
let type = 'add';
if (ReCharge.Novum.isSwap) {
isSwap = true;
ev.target.closest('[data-handler-type]').dataset.handlerType;
type = 'swap';
}
const data = await ReCharge.Actions.getProducts(6, null, url, isSwap);
ReCharge.Novum.Helpers.renderProducts(data.products, type);
}
const page = handlerType === 'upsell'
? this.currentUpsellPage
: this.currentAddPage;
this.updateButtonState(handlerType);
this.updateCurrentPageNumber(page, handlerType);
},
disableButtons: function(type) {
document
.querySelector(`.rct_pagination__prev--${type}`)
.classList.add('rct_pagination\_\_prev--disabled');
document
.querySelector(`.rct_pagination__next--${type}`)
.classList.add('rct_pagination__next--disabled');
},
enableButtons: function(type) {
document
.querySelector(`.rct_pagination__prev--${type}`)
.classList.remove('rct_pagination\_\_prev--disabled');
document
.querySelector(`.rct_pagination__next--${type}`)
.classList.remove('rct_pagination__next--disabled');
},
updateButtonState(type) {
const prevBtnAction = this.hasPrevMeta(type) ? 'remove' : 'add';
const nextBtnAction = this.hasNextMeta(type) ? 'remove' : 'add';
document
.querySelector(`.rct_pagination__prev--${type}`)
.classList[prevBtnAction]('rct_pagination__prev--disabled');
document
.querySelector(`.rct_pagination__next--${type}`)
.classList[nextBtnAction]('rct_pagination__next--disabled');
},
updateCurrentPageNumber: function(page = null, type) {
if (ReCharge.Novum.isSwap) {
document
.querySelector(`.rct_pagination__current--${type}`)
.innerText = page;
return;
}
return document
.querySelector(`.rct_pagination__current--${type}`)
.innerText = page;
},
toggle: function(shouldShow = null) {
if (shouldShow) {
return document
.querySelector(`.rct_pagination__container--${this.type}`)
.classList.remove('rct_pagination\_\_container--hidden');
}
document
.querySelector(`.rct_pagination__prev--${type}`)
.classList[prevBtnAction]('rct_pagination__prev--disabled');
document
.querySelector(`.rct_pagination__next--${type}`)
.classList[nextBtnAction]('rct_pagination__next--disabled');
document
.querySelector(`.rct_pagination__container--${this.type}`)
.classList.add('rct_pagination__container--hidden');
},
updatePagination: function() {
this.currentAddPage = 1;
this.updateButtonState('add');
this.updateCurrentPageNumber(this.currentAddPage, 'add');
if (!this.hasNextMeta(this.type)) {
return this.toggle();
}
},
renderInitialPagination: function() {
let page = this.currentAddPage;
if (ReCharge.Novum.Pagination.type === 'upsell') {
page = this.currentUpsellPage
}
if (
page === 1 &&
!this.hasNextMeta(this.type)
) {
this.toggle();
} else {
this.toggle(true);
}
},
updateBtnProps: function(type) {
let btn = document.querySelector(`.rct_pagination__${type}--add`);
if (btn) {
btn.classList.remove(`rct_pagination__${type}--add`);
btn.classList.add(`rct_pagination__${type}--upsell`);
btn.dataset.handlerType = 'upsell';
}
}
}
subscription.html
The old HTML structure for the pagination container was replaced with the included pagination snippet.
Old code
<ul class="rc__upsells--pagination_buttons_container"> </ul>
New code
{% include '_pagination.html' %}
For the add product button, an addition check has been added to check if the store allows adding a product.
Old code
\< button
class = "rc_btn border-light text-uppercase title-bold js-add-product-btn"
style = "display: none;"
onclick = "addProductHandler(event);" >
{{ 'Add_product' | t }}
</button>
New code
{% if settings.customer_portal.subscription.add_product%}
<button
class="rc_btn border-light text-uppercase title-bold"
onclick="addProductHandler(event);"
>
{{ 'cp_add_product_label' | t }}
</button>
{% endif %}
In the script tag the handler has been converted to an asynchronous function, and initial setup for pagination has been added.
Old code
document.addEventListener("DOMContentLoaded", () =>
{
ReCharge.Novum.Helpers.fetchChargesOnetimes();
const rcProducts = JSON.parse(sessionStorage.getItem('rc_products')) || null;
if (rcProducts)
{
if (rcProducts.length && ReCharge.Novum.settings.customer_portal.subscription.add_product)
{
ReCharge.Novum.Helpers.renderUpsells('upsell', rcProducts);
}
else
{
const upsellWrapper = document.querySelector(".upsells--wrapper") || null;
upsellWrapper.innerHTML = ReCharge.Utils.renderNoProductsLayout();
}
}
});
New code
document.addEventListener("DOMContentLoaded", async () =>
{
ReCharge.Novum.Helpers.fetchChargesOnetimes();
if (ReCharge.Novum.settings.customer_portal.subscription.add_product)
{
const data = await ReCharge.Actions.getProducts(12);
ReCharge.Novum.Pagination.type = 'upsell';
ReCharge.Novum.Pagination.updateBtnProps('container');
ReCharge.Novum.Pagination.updateBtnProps('prev');
ReCharge.Novum.Pagination.updateBtnProps('next');
ReCharge.Novum.Pagination.updateBtnProps('current');
ReCharge.Novum.Helpers.renderUpsells(data.products);
}
else
{
const upsellWrapper = document.querySelector(".upsells--wrapper") || null;
upsellWrapper.innerHTML = ReCharge.Utils.renderNoProductsLayout();
}
});
Updated 5 months ago