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:
- Log in as a test customer.
- Visit the portal at /tools/recurring.
- Confirm the Cancel subscription link appears when expected.
- Test both flows:
- Single subscription → redirect
- 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.
Updated 1 day ago