Skip to main content

Show punch card rewards progress

In this example you'll get the number of consecutive subscription-related charges processed for a customer and display it as progress towards rewards related goals.

Punch Card

Pre-requisites

  • You have a Shopify store.
  • You have access to Rewards in your Recharge account. (See Rewards)
  • You have a customer with an active subscription.
  • You have basic knowledge of HTML and JavaScript.

Explanation

In this example we will retrieve the customer's subscription_related_charge_streak and display it via a configurable Punch Card UI. See subscription_related_charge_streak for a more detailed description of this attribute.

Example Code

Methods and Types:

This example assumes you have already loaded the Recharge JavaScript SDK and it is available on the window.

<punch-card></punch-card>

<script>
export class PunchCard extends HTMLElement {
constructor() {
super();
this.render();
}

async render() {
this.innerHTML = `
<link rel="stylesheet" href="./punch-card.css">
<section class="rc-card punch-card">
<div class="punch-card-container">
<h2 class="punch-card-title"></h2>
<p class="punch-card-description">Loading tier information...</p>
<div class="punch-card-columns"></div>
<div class="progress-bar"></div>
<p class="next-tier-info"></p>
</div>
</section>
`;

try {
const config = await this.loadConfig();
if (!config?.component_configuration?.punch_card_config?.enabled) {
this.style.display = 'none';
return;
}

this.applyStyles(config);
await this.loadData(config);

// Trigger animations directly after data is loaded and the component is updated
this.triggerAnimations(config);
} catch (error) {
console.error('Error loading punch card component:', error);
this.style.display = 'none'; // Hide the component on error
}
}

async loadData(config) {
try {
this.setLoading(true);
await this.login();
const customer = await this.loadCustomer();

if (!customer || customer.subscription_related_charge_streak === undefined) {
this.style.display = 'none';
return;
}

const functionsConfig = config.functions || {};
this.streakCount = customer.subscription_related_charge_streak; // Store the streak count for later use
this.updateTitle(functionsConfig);

if (functionsConfig.display_current_tier !== false) {
this.displayPunchCard(
this.streakCount,
config.component_configuration.punch_card_config.tiers,
functionsConfig
);
} else {
this.querySelector('.punch-card-description').style.display = 'none';
}

if (functionsConfig.display_next_tier !== false) {
this.displayNextTierInfo(this.streakCount, config.component_configuration.punch_card_config.tiers);
} else {
this.querySelector('.next-tier-info').style.display = 'none';
}

if (functionsConfig.display_progress_bar !== false) {
this.displayProgressBar(this.streakCount, config.component_configuration.punch_card_config.tiers);
} else {
this.querySelector('.progress-bar').style.display = 'none';
}

// Trigger animations directly after the component is updated
this.triggerAnimations(config);
} catch (error) {
console.error('Error loading customer data or punch card:', error);
this.style.display = 'none';
} finally {
this.setLoading(false);
}
}

updateTitle(functionsConfig) {
const titleElement = this.querySelector('.punch-card-title');
if (functionsConfig.display_title !== false) {
titleElement.textContent = functionsConfig.title_text || 'Your Rewards Progress';
} else {
titleElement.style.display = 'none';
}
}

displayPunchCard(streakCount, tiers, functionsConfig) {
const punchCardDescription = this.querySelector('.punch-card-description');
const punchCardColumns = this.querySelector('.punch-card-columns');

const currentTier = tiers.find(
tier =>
streakCount >= (tier.streak_count_min || 0) &&
(tier.streak_count_max === null || streakCount <= tier.streak_count_max)
);

if (currentTier) {
punchCardDescription.innerHTML = `You are a ${currentTier.name}!`;
} else {
punchCardDescription.innerHTML = 'No tier information available.';
}

if (functionsConfig.display_benefit_columns !== false) {
punchCardColumns.innerHTML = tiers
.sort((a, b) => a.rank - b.rank)
.map((tier, index) => {
const isAchieved = streakCount >= (tier.streak_count_min || 0);
const circleClass = isAchieved ? 'filled' : 'outlined'; // Add 'outlined' class for unachieved tiers

const benefitsHTML = tier.benefits
.map(benefit => `<li class="benefit-item ${isAchieved ? '' : 'inactive'}">${benefit.display_text}</li>`)
.join('');

return `
<div class="punch-card-column">
<h3 class="tier-name" style="color: ${tier.color || 'var(--primary-color)'}">${tier.name}</h3>
<p class="tier-orders">${tier.streak_count_min || 0} to ${tier.streak_count_max || 'more'} Orders</p>
${
functionsConfig.display_punches !== false
? `<div class="circle ${circleClass}" data-index="${index}"></div>`
: ''
}
<ul class="benefits">
${benefitsHTML}
</ul>
</div>
`;
})
.join('');
} else {
punchCardColumns.style.display = 'none';
}
}

triggerAnimations(config) {
const functionsConfig = config.functions || {}; // Safely access the functions configuration
const streakCount = this.streakCount || 0; // Retrieve streak count from class property
const tiers = config.component_configuration.punch_card_config.tiers;

if (functionsConfig.display_punches !== false) {
this.animateTiers(streakCount, tiers);
}

if (functionsConfig.display_progress_bar !== false) {
this.animateProgressBar(streakCount);
}
}

// Animate each tier circle sequentially
animateTiers(streakCount, tiers) {
const sortedTiers = tiers.sort((a, b) => a.rank - b.rank);
let lastAnimationDelay = 0;

sortedTiers.forEach((tier, index) => {
const circle = this.querySelector(`.circle[data-index="${index}"]`);
if (circle) {
lastAnimationDelay = index * 500; // Store the delay of the last pop animation
setTimeout(() => {
if (streakCount >= (tier.streak_count_min || 0)) {
circle.classList.add('pop-in');
}
}, lastAnimationDelay);
}
});

// After the last circle animation, trigger the wiggle effect
setTimeout(() => {
this.triggerWiggleEffect(sortedTiers, streakCount);
}, lastAnimationDelay + 500); // Delay to start wiggle after the pop-in animations
}

triggerWiggleEffect(tiers, streakCount) {
const nextTierInfo = this.querySelector('.next-tier-info');
if (nextTierInfo) {
nextTierInfo.classList.add('wiggle');
}

const firstUnachievedIndex = tiers.findIndex(tier => streakCount < (tier.streak_count_min || 0));
let firstUnachievedCircle;
if (firstUnachievedIndex !== -1) {
firstUnachievedCircle = this.querySelector(`.circle[data-index="${firstUnachievedIndex}"]`);
if (firstUnachievedCircle) {
firstUnachievedCircle.classList.add('wiggle');
}
}

// Remove the wiggle effect after a short delay
setTimeout(() => {
if (nextTierInfo) nextTierInfo.classList.remove('wiggle');
if (firstUnachievedCircle) firstUnachievedCircle.classList.remove('wiggle');
}, 1000); // Wiggle for 1 second
}

displayNextTierInfo(streakCount, tiers) {
const sortedTiers = tiers.sort((a, b) => a.rank - b.rank);
const currentTierIndex = sortedTiers.findIndex(
tier =>
streakCount >= (tier.streak_count_min || 0) &&
(tier.streak_count_max === null || streakCount <= tier.streak_count_max)
);

const nextTier = sortedTiers[currentTierIndex + 1];
const nextTierInfo = this.querySelector('.next-tier-info');

if (nextTier && nextTier.streak_count_min !== null) {
const ordersNeeded = nextTier.streak_count_min - streakCount;
nextTierInfo.innerHTML = `Place ${ordersNeeded} more orders to get to the ${nextTier.name} Tier.`;
} else {
nextTierInfo.innerHTML = '';
}
}

displayProgressBar(streakCount, tiers) {
const highestTier = tiers.reduce(
(max, tier) => {
return tier.streak_count_min > max.streak_count_min ? tier : max;
},
{ streak_count_min: 0 }
);

const sectionsCount = highestTier.streak_count_min;
const progressBar = this.querySelector('.progress-bar');

// Set the CSS variable for section count
progressBar.style.setProperty('--sections-count', sectionsCount);

progressBar.innerHTML = Array.from({ length: sectionsCount })
.map((_, index) => {
return `<div class="progress-section" data-index="${index}"></div>`;
})
.join('');

// Animate the progress bar after it has been built
this.animateProgressBar(streakCount);
}

animateProgressBar(streakCount) {
const progressBarSections = this.querySelectorAll('.progress-section');
progressBarSections.forEach((section, index) => {
if (index < streakCount) {
setTimeout(() => {
section.classList.add('filled');
}, index * 100); // Adjust delay for sequential filling
}
});
}

async login() {
if (!this.session) {
this.session = await getRechargeSession();
if (!this.session || !this.session.customerId) {
throw new Error('Session is missing or customer is not authenticated.');
}
}
}

async loadCustomer() {
try {
const customer = await recharge.customer.getCustomer(this.session);
return customer;
} catch (error) {
console.error('Error fetching customer data:', error);
throw error;
}
}

async loadConfig() {
try {
const response = await fetch('./config.json');
if (!response.ok) throw new Error('Failed to load config file');
const config = await response.json();
return config;
} catch (error) {
console.error('Error loading config:', error);
return null;
}
}

setLoading(value) {
const loader = this.querySelector('.subscriptions-loader');
if (loader) {
loader.style.display = value ? 'block' : 'none';
}
}

applyStyles(config) {
const styles = config.component_configuration.styling || {};
const backgroundColor = styles.background_color_hex_code || '#FFFFFF';
const fontColors = styles.font_color_hex_codes || {
primary: '#FF6F61',
primary_variant: '#D9534F',
secondary: '#4A90E2',
secondary_variant: '#4178BE',
inactive_color: '#D3D3D3',
};

this.style.setProperty('--background-color', backgroundColor);
this.style.setProperty('--primary-color', fontColors.primary);
this.style.setProperty('--primary-variant-color', fontColors.primary_variant);
this.style.setProperty('--secondary-color', fontColors.secondary);
this.style.setProperty('--secondary-variant-color', fontColors.secondary_variant);
this.style.setProperty('--inactive-color', fontColors.inactive_color);
}
}

// Define the custom element
customElements.define('punch-card', PunchCard);
</script>

punch-card.css - must be placed in the same directory as the above file or the call to this file must be updated

.punch-card {
padding: 20px;
background-color: var(--background-color);
}

.punch-card-title {
text-align: center;
color: var(--primary-color);
}

.punch-card-description {
text-align: center;
color: var(--secondary-color);
}

.punch-card-columns {
display: flex;
justify-content: space-around;
gap: 20px;
margin-top: 20px;
}

.punch-card-column {
flex: 1;
text-align: center;
margin: 0 10px;
min-width: 150px;
}

.tier-name {
font-size: 1.2em;
margin-bottom: 10px;
}

.tier-orders {
margin-bottom: 10px;
font-weight: bold;
}

/* Circle styles */
.circle {
width: 50px;
height: 50px;
border-radius: 50%;
border: 2px solid var(--primary-color);
margin: 0 auto;
background-color: transparent; /* Default transparent for unachieved */
}

.circle.filled {
background-color: var(--primary-color);
}

.circle.outline {
background-color: transparent;
border: 2px dashed var(--inactive-color); /* Dashed outline for unachieved */
}

.circle.pop-in {
animation: pop-in 0.5s ease-out forwards;
}

@keyframes pop-in {
0% {
transform: scale(0.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}

.benefits {
list-style-type: none;
padding: 0;
}

.benefit-item {
margin: 5px 0;
padding: 5px;
border: 2px solid var(--primary-color);
border-radius: 10px;
background-color: var(--secondary-color);
color: #fff;
}

/* Inactive benefit item styling */
.benefit-item.inactive {
background-color: var(--inactive-color);
border-color: var(--inactive-color);
color: #888; /* Optional: Change text color for inactive benefits */
}
.punch-card-column.inactive .benefit-item {
background-color: var(--inactive-color);
border-color: var(--inactive-color);
}

.next-tier-info {
text-align: center;
margin-top: 20px;
color: var(--primary-variant-color);
font-weight: bold;
}

/* Progress bar styling */
.progress-bar {
display: flex;
height: 20px;
background-color: var(--inactive-color);
border-radius: 10px;
overflow: hidden;
margin: 20px 0;
}

/* Individual sections of the progress bar */
.progress-section {
flex: 1;
background-color: var(--inactive-color);
transition: background-color 0.1s ease-in-out; /* Smooth transition */
}

/* Filled sections of the progress bar */
.progress-section.filled {
background-color: var(--primary-color);
}

/* wiggle animation */
@keyframes wiggle {
0% {
transform: translate(1px, 1px) rotate(0deg);
}
10% {
transform: translate(-1px, -2px) rotate(-1deg);
}
20% {
transform: translate(-3px, 0px) rotate(1deg);
}
30% {
transform: translate(3px, 2px) rotate(0deg);
}
40% {
transform: translate(1px, -1px) rotate(1deg);
}
50% {
transform: translate(-1px, 2px) rotate(-1deg);
}
60% {
transform: translate(-3px, 1px) rotate(0deg);
}
70% {
transform: translate(3px, 1px) rotate(-1deg);
}
80% {
transform: translate(-1px, -1px) rotate(1deg);
}
90% {
transform: translate(1px, 2px) rotate(0deg);
}
100% {
transform: translate(1px, -2px) rotate(-1deg);
}
}

/* Apply wiggle effect */
.wiggle {
animation: wiggle 0.5s ease-in-out;
animation-iteration-count: 2; /* Wiggle twice */
}

config.json - must be placed in the same directory as the above file or the call to this file must be updated

This file can be updated as need to configure the component. The example below is a sample configuration.

{
"component_configuration": {
"styling": {
"background_color_hex_code": "#F9F9F9",
"font_color_hex_codes": {
"primary": "#FF6F61",
"primary_variant": "#D9534F",
"secondary": "#4A90E2",
"secondary_variant": "#4178BE",
"inactive_color": "#D3D3D3"
}
},
"functions": {
"title_text": "Your Rewards Summary",
"display_title": true,
"display_current_tier": true,
"display_benefit_columns": true,
"display_progress_bar": true,
"display_next_tier": true,
"display_punches": true
},
"punch_card_config": {
"enabled": true,
"tiers": [
{
"name": "Silver Subscriber",
"streak_count_min": 0,
"streak_count_max": 5,
"rank": 1,
"benefits": [
{
"name": "discount",
"display_text": "Save 10% on every order"
}
]
},
{
"name": "Gold Subscriber",
"streak_count_min": 6,
"streak_count_max": 15,
"rank": 2,
"benefits": [
{
"name": "discount",
"display_text": "Save 15% on every order"
}
]
},
{
"name": "Platinum Subscriber",
"streak_count_min": 16,
"streak_count_max": null,
"rank": 3,
"benefits": [
{
"name": "discount",
"display_text": "Save 10% on every order"
},
{
"name": "free gift",
"display_text": "Get a free Super Tomato added to your next order!"
}
]
}
]
}
}
}