If you run a SaaS, you probably want to show your users when they are almost running out of widgets. Or that they can get some cool feature on a more expensive plan. Or, in other words, how can you be nice and commercial in dealing with plan limits.
Last week we already looked at how we manage plans & features for Checkly. That write up was very back end focused, so this week I wanted to dive deeper into how we actually show this to our users in a friendly way.
We use Vue.js with Vuex for our front end, but the patterns and code examples here can be applied to any other SPA framework.
Types of plan limits
Short recap of the types of plan limits we recognized in the last writeup.
- Paying vs. lapsing: You are a paying customer or on a trial / stopped paying.
- Plan based feature toggles: A feature is enabled on your plan or not.
- Plan based volume limits: You are allowed ten of these and five of those.
We also mentioned role based access control but I wanted to keep that for another write up.
The basic setup
We need to keep track of a bunch of fairly global variables, some fairly static β plan expiry date for yearly payers changes once a yearβ some that dynamically change as the user interacts with the app.
However, we do not want to bother all of our frontend components with the logic to control and validate these cross cutting concerns. We want to expose a dedicated β dare I say singleton β object that encapsulates the current state of all of plan and user logic.
For this we use Vue.js and Vuex, a Redux-type central data store. On initial page load, we populate an object using actions and mutations (two very specific Vuex things I won't go into much deeper here) with the things we are interested in.
Or, in pre-Javascript-frameworks-are-eating-the-world-speak, you fire an XHR request when a user logs in, your backend returns all account data, you parse it into a palatable object.
Here is what such an object looks like. It's an almost exact copy & paste from the excellent Vue.js debug tool.
{
isPayingCustomer: true,
currentAccount: {
features: ['SMS_ALERTS', 'TEAMS', 'PROMETHEUS', 'TRIGGERS']
},
expiryStatus: {
daysTillTrialExpiry: 24
planHasExpired: false
},
isFeatureLimited: {
accountUsers: true
apiChecks: true
browserChecks: false
dashboards: false
},
}
Notice a couple of things:
- We transform almost all properties into
isSomething
orhasSomething
forms. This makes your code nicer in the components that use this later. - We have a
currentAccount
object because a user can be a member of multiple accounts and can switch between them during a session. - Strictly speaking, the
expiryStatus
object holds superfluous data. But we don't want every component that uses this to implement the boolean functionplanHasExpired
based on thedaysTillTrialExpiry
property. - This representation is pretty different from how we store it on our backend. It is specifically tailored to be useful in the frontend.
That last bullet is kinda important, I figured out after a while. Here comes a quote:
How things are stored on your backend should never determine how you use them in your frontend.
This is probably material for another post, but very essential to self starting, full stack developers. You need to cross the chasm. Backend and frontend are not the same.
Let's look at some examples now.
Example 1: Plan expiry nag screen
This is what appears at the top of you navigation bar in Checkly if you are dangerously close to your plan expiring. This happens only on two occasions:
- You are a trial user and haven't upgraded yet.
- You are a paying member of our secret and exclusive society, but for some unspoken reason your credit card failed.
To conjure up this message, we use the following code. Note we use Jade/Pug for templating but it should translate to plain HTML quite easily.
.navbar-upgrade-notice(v-if='showUpgradeTeaser')
| You have only {{expiryStatus.daysTillTrialExpiry}} day(s) left in your trial!
router-link(:to="{ name: 'billing:plans' }") Upgrade your plan
.navbar-upgrade-notice(v-if='showExpiredTeaser')
| Your trial has expired!
router-link(:to="{ name: 'billing:plans' }") Upgrade your plan
Two things are happening here:
- We have an
if
statement on theshowUpgradeTeaser
andshowExpiredTeaser
booleans. If they are false, we don't show 'm. You get it. - We directly use the
expiryStatus
object and tap into thedaysTillTrialExpiry
property to let the user know how long he/she has.
But how do we get this data from the central data store? And how do we set that showUpgradeTeaser
property? For this we leverage Vue.js's computed properties. They are absolutely awesome and I use them as much as I can.
Simply put, they are properties they are constantly updated based in changing inputs. "Reactive" if you will. In most frameworks, this code lives in the controller of your frontend component, although Vue.js does not call them that.
Here's a look at a part of the code of our navigation bar component.
computed: {
expiryStatus() {
this.$store.getters.expiryStatus
},
showUpgradeTeaser () {
return this.expiryStatus
? (this.expiryStatus.daysTillTrialExpiry > 0
&& this.expiryStatus.daysTillTrialExpiry < 5) : false
},
showExpiredTeaser () {
return this.expiryStatus ? this.expiryStatus.planHasExpired : false
}
}
You can see how the showUpgradeTeaser
and showExpiredTeaser
are created. They directly tap into the expiryStatus
object, which is exposed to the local this
context by a very Vue.js specific way of getting data from a Vuex store. Your framework will have a similar thing. Also notice we start show the upgrade teaser from the last five days till a plan expires.
Example 2: plan volume limit reached
This is what a user sees when they try to create one more check when they already are at their plan limit.
We explicitly want a user to be notified about his/her plan limit the moment that creating a new check is relevant. There is probably a very good commercial reason for that and that is why all SaaS companies do it [citation needed].
Here's a snippet of our frontend code. It follows the exact same pattern as the example above:
.dropdown-item(v-if='isFeatureLimited.apiChecks || expiryStatus.planHasExpired')
.check-icon
.title API check
router-link(:to="{ name: 'billing:plans' }") Upgrade your plan
.button-text You maxed out the API checks in your account.
Again, it taps into the expiryStatus
object but this time also into the isFeatureLimited
object. Together, they decide whether to show the upgrade button (and block creating a new check) or not.
The isFeatureLimited
object encapsulates the state of a plan and if it is over its assigned volume limits for a specific resource; in our case API checks and browser checks.
This is actually a bit more complicated than it seems. We, again, deal with it in our central data store. Here's a snippet:
isFeatureLimited: (state, getters) => {
return {
apiChecks: getters.checks.filter(check => {
return check.checkType === 'API'
}).length >= getters.currentAccount.maxApiChecks
}
},
The property apiChecks
is dynamically generated based on two other properties in our data store:
-
checks
, an array of all checks which we first filter on check type and then count. Add a check or remove a check and this gets updated on the fly. -
currentAccount.maxApiChecks
, a property determined by the plan the user is currently on. Upgrade and you get more, automatically bumping this value.
We do the exact same thing for all other volume limited resources like browser checks, team members and dashboards.
Example 3: Plan feature toggle
Here's what you see when your plan does not have a specific feature, in this case the Pagerduty integration which is not in our Developer plan.
This one looks the simplest, but I actually encountered this pattern so often I abstracted it a bit more. I expect Checkly's feature set to grow quite a bit so having a fairly generic way of dealing with this is very handy. Here's the gist:
.pagerduty
.header Pagerduty
span(v-if='$planHasFeature("PAGERDUTY")')
// Pagerduty integration settings
span(v-else)
feature-not-available
There are two things going on here:
First, we check if the current plan has the feature PAGERDUTY
enabled. Instead of using a component specific property, we use a global mixin to expose a function called $planHasFeature()
to all templated elements.
What does this function do? Nothing more than checking the central data store if the currentAccount.features
array holds the feature we pass into the function. The code is below.
const hasFeature = {
created () {
this.$planHasFeature = function (feature) {
return this.features.includes(feature)
}
},
computed: {
features () {
return this.$store.getters.currentAccount.features
}
}
}
Second, if this plan does not have this feature, we render a generic feature-not-available
component. This is just a nice button that takes you to our upgrade page. This component is already used in nine other components, so I guess the extra abstraction was worth it.
With these patterns you can cater for a ton of common SaaS thing like showing upgrade messages and counter for volume based features. Hope it helps!
Top comments (0)