Affinity extension: Cancel subscription navigation

Enhance your Affinity customer portal with a smart "Cancel subscription" navigation link that adapts to single and multiple subscriptions.

Add a dynamic Cancel subscription link to the Affinity customer portal sidebar. This extension adapts to customers with single or multiple subscriptions and integrates with Recharge's cancellation prevention flow.

This guide explains how to add the extension to your theme file.


How it works

Sidebar navigation link

The extension adds a Cancel subscription link to the Affinity sidebar. When clicked:

  • If the customer has one active subscription, they’re redirected to Recharge’s cancellation prevention page.
  • If the customer has multiple subscriptions, a modal appears so they can select which one to cancel.

Modal behavior

When a customer has multiple active subscriptions, the modal displays:

  • Each product and variant title
  • A Cancel button per item

Clicking Cancel sends the customer to the appropriate churn prevention URL for that subscription.

Event handling

The extension listens for the following Recharge events:

  • Recharge::slot::mounted: Initializes the cancel link after the sidebar loads.
  • Recharge::action::orderChanged: Refreshes the subscription list when changes occur (e.g. a new subscription is added).

Features

  • Smart navigation: Visible only when the customer has active subscriptions.
  • Adaptive flow: Automatically handles both single and multiple subscription cases.
  • Clean modal: Provides a user-friendly modal when multiple subscriptions exist.
  • Direct redirects: Integrates with Recharge’s cancellation prevention flow.
  • Responsive design: Fully compatible with both mobile and desktop experiences.

Set-up instructions

Step 1 - Add the snippet

Create a new snippet in your Shopify theme called affinity-extension-nav-cancel.liquid, and paste in the contents of the provided file.

{% assign apiToken = 'strfnt_xxx' %}

<style>
  .aff-modal-wrapper {
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    opacity: 0;
    z-index: var(--recharge-app-modal-zIndex);
    background: rgba(0, 0, 0, 0.25);
    opacity: 1;
    transition-property: opacity;
    transition-timing-function: ease;
    transition-duration: var(0.3s);
    will-change: opacity;
    transform: translateY(0);
    display: flex;
    justify-content: center;
    align-items: flex-end;
    isolation: isolate;
    cursor: pointer;
  }
  .aff-modal {
    opacity: 1;
    transform: translateY(0);
    width: 600px;
    max-width: 100vw;
    max-height: 80vh;
    overflow: auto;
    background-color: var(--recharge-cards-background);
    transition-property: opacity, transform;
    transition-timing-function: ease;
    transition-duration: 0.3s;
    will-change: opacity, transform;
    padding: 24px;
    border-radius: var(--recharge-corners-radius) var(--recharge-corners-radius)
      0 0;
  }
  @media screen and (min-width: 1024px) {
    .aff-modal-wrapper {
      align-items: center;
    }
    .aff-modal {
      border-radius: var(--recharge-corners-radius);
    }
  }
  .aff-button {
    background-color: var(--recharge-button-brand);
    border: 2px solid var(--recharge-button-brand);
    border-radius: var(--recharge-button-border-radius);
    color: var(--recharge-button-color);
    font-size: var(--recharge-typography-size-5);
    font-weight: 600;
    line-height: 150%;
    padding: 10px 16px;
    text-align: center;
    cursor: pointer;
    display: inline-block;
  }
  .aff-button:hover {
    background-color: var(--recharge-color-brand-120);
    border-color: var(--recharge-color-brand-120);
  }
  .aff-button[disabled=true] {
    opacity: 0.6;
  }
  .aff-button-secondary {
    border:  2px solid var(--recharge-color-brand-85);
    background: var(--recharge-color-brand-85);
    color: var(--recharge-color-brand);
  }
  .aff-button-secondary:hover {
    background: var(--recharge-color-brand-75);
    border-color: var(--recharge-color-brand-75);
  }
  .aff-h3 {
    font-size: var(--recharge-typography-size-3);
    font-weight: 600;
    line-height: 123%;
  }
  .aff-h4 {
    font-size: var(--recharge-typography-size-4);
    font-weight: 600;
    line-height: 140%;
  }
  .aff-nav-item {
    border-radius: var(--recharge-corners-radius); 
    background: var(--recharge-cards-background); 
    padding: 20px;
    line-height: 1.5; 
  }
  .aff-nav-item_anchor {
    display: flex!important; 
    flex-wrap: nowrap;
    justify-content: space-between; 
    align-items:center;
  }
  .aff-nav-item_icon {
    width: 24px; 
    height: 24px;
    padding:6px;
    line-height:0;
    color: var(--recharge-button-brand);
  }
  .t-mb3 {
    margin-bottom: 16px;
  }
  .t-db {
    display: block;
  }
  .t-w-100 {
    width: 100%;
  }
  .subscription-content {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 8px;
  }
  .subscription-info {
    flex: 1;
  }
  .subscription-details {
    font-size: var(--recharge-typography-size-6);
    color: var(--recharge-text-secondary);
  }
</style>

<script src="https://static.rechargecdn.com/assets/storefront/recharge-client-1.36.0.min.js"></script>
<script>
  recharge.init({storefrontAccessToken: '{{ apiToken }}'});
</script>

{% comment %} Cancel Link Slot {% endcomment %}
<script type="text/html" data-recharge-slot="*.sidebar">
  <div id="cancel-subscription-nav" class="aff-nav-item" style="display: none;">
    <a id="cancel-subscription-link" class="aff-nav-item_anchor" href="#">
      <span class="aff-h4">Cancel subscription</span>
      <div class="aff-nav-item_icon">
        <svg viewBox="0 0 10 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="m1.5 15 7-7-7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
      </div>
    </a>
  </div>
</script>

{% comment %} Multiple Subscriptions Modal {% endcomment %}
<div
  id="subscription-selection-modal"
  class="recharge-theme aff-modal-wrapper"
  style="display: none"
  onclick="window.SubscriptionSelectionModal.close(event)">
  <div class="aff-modal" onclick="event.stopPropagation();">
    <span class="aff-h3 t-mb3 t-db">Subscriptions</span>
    <div id="subscription-list" class="t-mb3">
      <!-- Subscription items will be populated here -->
    </div>
    <button class="aff-button t-db t-w-100" onclick="window.SubscriptionSelectionModal.close(event)">
      Close
    </button>
  </div>
</div>

<script>
const rechargeAPI = {
  session: null,
  subscriptions: null,

  async authenticate() {
    try {
      if (this.session) {
        return this.session;
      }
      this.session = await recharge.auth.loginCustomerPortal();
      return this.session;
    } catch (error) {
      console.error("Could not authenticate:", error);
      throw error;
    }
  },

  async getSubscriptions() {
    try {
      if (this.subscriptions) {
        return this.subscriptions;
      }

      const response = await recharge.subscription.listSubscriptions(this.session, {
        limit: 10,
        sort_by: 'id-asc',
        status: 'Active'
      });
      this.subscriptions = response.subscriptions;
      return this.subscriptions;
    } catch (error) {
      console.error("Could not fetch subscriptions:", error);
      throw error;
    }
  },

  async getActiveChurnLandingPageURL(subscriptionId) {
    try {
      const response = await recharge.customer.getActiveChurnLandingPageURL(this.session, subscriptionId, window.location.href);
      return response;
    } catch (error) {
      console.error("Could not get churn landing page URL:", error);
      throw error;
    }
  }
};

const cancelSubscriptionHandler = {
  async init() {
    try {
      await rechargeAPI.authenticate();
      const subscriptions = await rechargeAPI.getSubscriptions();
      
      if (subscriptions && subscriptions.length > 0) {
        this.showCancelLink();
        this.setupEventListeners(subscriptions);
      }
    } catch (error) {
      console.error('Failed to initialize cancel subscription handler:', error);
    }
  },

  showCancelLink() {
    const cancelNav = document.getElementById('cancel-subscription-nav');
    if (cancelNav) {
      cancelNav.style.display = 'block';
    }
  },

  setupEventListeners(subscriptions) {
    const cancelLink = document.getElementById('cancel-subscription-link');
    if (cancelLink) {
      cancelLink.addEventListener('click', (e) => {
        e.preventDefault();
        this.handleCancelClick(subscriptions);
      });
    }
  },

  async handleCancelClick(subscriptions) {
    if (subscriptions.length === 1) {
      // Single subscription - redirect directly
      await this.redirectToChurnPage(subscriptions[0].id);
    } else {
      // Multiple subscriptions - show modal
      this.showSubscriptionSelectionModal(subscriptions);
    }
  },

  async redirectToChurnPage(subscriptionId) {
    try {
      const churnUrl = await rechargeAPI.getActiveChurnLandingPageURL(subscriptionId);
      window.location.href = churnUrl;
    } catch (error) {
      console.error('Failed to redirect to churn page:', error);
      alert('Unable to process cancellation. Please try again or contact support.');
    }
  },

  showSubscriptionSelectionModal(subscriptions) {
    const modal = document.getElementById('subscription-selection-modal');
    const subscriptionList = document.getElementById('subscription-list');
    
    // Clear existing content
    subscriptionList.innerHTML = '';
    
    // Populate subscription list
    subscriptions.forEach(subscription => {
      const subscriptionItem = document.createElement('div');
      subscriptionItem.className = 't-mb3';
      
      // Get product and variant titles
      const productTitle = subscription.product_title || 'Product';
      const variantTitle = subscription.variant_title || '';
      
      subscriptionItem.innerHTML = `
        <div class="subscription-content">
          <div class="subscription-info">
            <div class="aff-h4">${productTitle}</div>
            ${variantTitle ? `<div class="subscription-details">${variantTitle}</div>` : ''}
          </div>
          <button class="aff-button aff-button-secondary" data-subscription-id="${subscription.id}">
            Cancel
          </button>
        </div>
      `;
      
      // Add event listener to the cancel button
      const cancelButton = subscriptionItem.querySelector('.aff-button');
      cancelButton.addEventListener('click', (e) => {
        e.stopPropagation();
        this.handleIndividualCancel(subscription.id, cancelButton);
      });
      
      subscriptionList.appendChild(subscriptionItem);
    });
    
    modal.style.display = 'flex';
  },

  async handleIndividualCancel(subscriptionId, buttonElement) {
    // Disable the button to prevent multiple clicks
    buttonElement.disabled = true;
    buttonElement.textContent = 'Redirecting...';
    
    try {
      await this.redirectToChurnPage(subscriptionId);
    } catch (error) {
      console.error('Failed to cancel subscription:', error);
      // Re-enable the button if there's an error
      buttonElement.disabled = false;
      buttonElement.textContent = 'Cancel';
      alert('Unable to process cancellation. Please try again or contact support.');
    }
  }
};

// Modal management
window.SubscriptionSelectionModal = {
  modal: document.getElementById("subscription-selection-modal"),
  open: function() {
    this.modal.style.display = "flex";
  },
  close: function() {
    this.modal.style.display = "none";
  }
};

// Initialize when slot is mounted
document.addEventListener("Recharge::slot::mounted", (event) => {
  if (event.detail.name === "sidebar") {
    cancelSubscriptionHandler.init();
  }
});

// Re-initialize when order changes occur
document.addEventListener("Recharge::action::orderChanged", (event) => {
  console.log("Order changed detected, re-initializing cancel subscription handler");
  // Clear cached subscriptions to ensure fresh data is fetched
  rechargeAPI.subscriptions = null;
  cancelSubscriptionHandler.init();
});
</script> 

At the top of the file, update the apiToken variable with your Recharge Storefront Access Token:

{% assign apiToken = 'your_recharge_storefront_access_token_here' %}

📘

Note

Learn how to generate a storefront access token.

Step 2 - Include the snippet in your theme

Edit your theme.liquid file to render the snippet only on the Affinity customer portal page:

{% if request.path contains "/tools/recurring" %}
  {% render 'affinity-extension-nav-cancel' %}
{% endif %}

Step 3 - Test the installation

After installation:

  1. Log in as a test customer.
  2. Visit the portal at /tools/recurring.
  3. Confirm the Cancel subscription link appears when expected.
  4. Test both flows:
    1. Single subscription → redirect
    2. Multiple subscriptions → modal

Once you test the extension, it’s live and matches your portal’s styling.


Code overview

Authentication and API

The rechargeAPI object manages:

  • Authenticating the logged-in customer
  • Fetching their active subscriptions
  • Getting churn prevention URLs from Recharge

Flow logic

The cancelSubscriptionHandler object is responsible for:

  • Checking the number of active subscriptions
  • Injecting the cancel link into the sidebar
  • Handling click events and modal display

Style

The extension inherits styles from the Affinity design system for seamless visual integration with your portal.