DEV Community

Hugo Di Francesco
Hugo Di Francesco

Posted on • Originally published at codewithhugo.com on

Add a Vanilla JavaScript paywall to a Hugo site using checkoutpage.co

How I created the buying mechanism for the Sequelize ES6 Cheatsheet using checkoutpage.co and sprinkles of vanilla JavaScript.

This strictly isn’t a paywall, it’s all client-side and doesn’t check much.

In principle you can display the whole things using a bit of CSS magic. You could also read the source and reverse-engineer what query params work for this page.

You could also spoof as Google Bot (or another crawler) and skip the pay overlay. If someone shares the link containing the “paid” query param whoever they’re sharing with will see it without paying.

I don’t think it’s likely that people will go through the effort of doing the above reverse-engineering, so I’m going to walk anyone who will listen through how I’ve implemented it.

If you like getting the cheatsheet for free but also want to support my work you can Buy me a Coffee or just subscribe to my mailing list.

Table of contents:

Set up hiding of content

I use the following CSS:

.site-wrapper.js-site-wrapper--no-scroll {
  top: 0;
  left: 0;
  width: 100%;
  z-index: 1;
  position: fixed;
}

.js-site-wrapper--no-scroll .post-full__table-of-contents {
  height: 100%;
  overflow: hidden;
}

This won’t work if your content is already inside of a relative, absolute or fixed position container but it works for me 🤷‍♀️.

The corresponding JS code is pretty simple:

<script>
  (function (document) {
    const SITE_WRAPPER_SELECTOR = '.js-site-wrapper';
    const SITE_WRAPPER_NO_SCROLL_CLASS = 'js-site-wrapper--no-scroll';

    $siteWrapper = document.querySelector(SITE_WRAPPER_SELECTOR);
    function didBuy() {
      return false;
    }
    function init() {
      if (!didBuy()) {
        // disable scroll/hide all the things
        $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
      }
    }
    init();
  })(document);
</script>

Everything is wrapped in an IIFE (immediately invoked function expression) to be nice and old school (and not pollute the global namespace). We pass the document into the IIFE (})(document);) and define it as a parameter (function (document)).

I like to set selectors and classes as constants and prefix elements with $ eg. $siteWrapper, again a bit of a throwback to jQuery days.

Just display everything when crawler or old browser

This is inspired by Swizec’s ES6 cheatsheet source.

We want to display everything (no no-scroll class) to crawlers.

We start by adding a crawler regex pattern from observablehq.com/@hugodf/crawler-regex which fetches from github.com/monperrus/crawler-user-agents under the hood and concatenates the individual patterns.

We turn that into a fully-fledged RegEx object that doesn’t care about case (hence the 'i' flag).

The new code in didBuy, is still not the most complex:if we don’t have a navigator object, just bail (ie. pretend like the person bought it), since it’s likely an old browser.If we get this far, check the user agent against crawler regular expression, display everything if it’s Google Bot or another crawler.

<script>
  (function (document, navigator) {
    // Get from https://beta.observablehq.com/@hugodf/crawler-regex
    const crawlerPattern = "REPLACE_ME";
    const crawlerRegEx = new RegExp(crawlerPattern, 'i');
    const SITE_WRAPPER_SELECTOR = '.js-site-wrapper';
    const SITE_WRAPPER_NO_SCROLL_CLASS = 'js-site-wrapper--no-scroll';

    $siteWrapper = document.querySelector(SITE_WRAPPER_SELECTOR);

    function isCrawler(userAgent) {
      return crawlerRegEx.test(userAgent);
    }

    function didBuy() {
      if (typeof navigator === "undefined" || typeof window === "undefined") {
        return true;
      }
      if (isCrawler(navigator.userAgent)) {
        return true;
      }
      return false;
    }
    function init() {
      if (!didBuy()) {
        // disable scroll/hide all the things
        $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
      }
    }
    init();
  })(document, navigator);
</script>

Setup checkout page

checkoutpage.co: “Checkout pages and forms to sell your products and services. Installed in minutes without coding”. It’s a simple and great way to add a payment page to your site. It just integrates with your Stripe account and gives you URLs to point to.

Let’s set it up, create an account, link it to Stripe, create a new page. Fill in whatever fields you feel like/are required. For checkoutpage.co to work with our client-side paywall code, we’ll have to do a couple of things.

The “Return URL” (Page → Settings → Return URL) should have a query param which will allow us to know someone has paid, that’s as simple as appending ?key=has-paid to the URL.

Your complete URL should be of the form: https://your-domain.tld/path/to/cheatsheet/page?key=has-paid.

We should add an email collection field so we can send a confirmation email. Let’s do so at Page → Fields → Add Field with the following data:

  • Label: Email
  • DataType: Customer Email
  • required: true

If we want to collect the email addresses to add to a mailing list and we want to be GDPR-compliant, we need an opt-in field.

To do so go to Page → Fields → Add Field and create a new field with the following options:- Label: “I would like to receive the Code with Hugo newsletter”- DataType: unselected (free text field)- Placeholder: “No”, this is important and makes this an opt-in- required: false

Set up a custom “Confirmation Message” email so users can pay once and keep accessing the cheatsheet (eg. from different devices). That’s at Page → Settings → Confirmation Message.

You can use the same query params ?key=has-paid or use a different one. Here’s my confirmation email:

Hey there 👋,

Thanks for buying the Sequelize ES6 cheat sheet.

This email confirms your payment. 

You can access the Sequelize ES6 cheat sheet using this link: https://codewithhugo.com/guides/sequelize-cheatsheet?key=list.

Payment details

Paid for: Sequelize ES6 Cheatsheet 
Paid to: Hugo 
Amount: $1.99

If you've opted in, you'll be added to the Code with Hugo newsletter. You can easily unsubscribe from the mailing list by clicking the unsubscribe link at the bottom of each email.

Checkoutpage will add some extra text after our message when the confirmation email gets sent.

Add a button that points to checkoutpage.co

I leave this as an implementation detail since Sander has done awesome work with his Buy buttons.

tl;dr You literally just need to point a link to your checkoutpage page 🙂

Check the query for key-values sets that are allowed

Now we want to set some values which will be valid “paid” params and the current page’s query params against that.

That means creating a getQueryObject function which takes window.location.search, removes ? takes each &key=value to convert them into an object like { key: "value" }.

We then take this queryObject and match it against our PAID_QUERY_PARAMS. If for a PAID_QUERY_PARAMS object, all the query keys match the values, then the user has paid. This is the snippet that does the work:

const bought = PAID_QUERY_PARAMS.some(function (queryParams) {
  return Object.entries(queryParams).every(function(keyValue) {
    const key = keyValue[0];
    const value = keyValue[1];
    return query[key] === value;
  });
});

To be semi-compatible with slightly older browsers I didn’t use arrow functions/destructuring although doing so would make the code more readable, in ES6+ I would do:

const bought = PAID_QUERY_PARAMS.some((queryParams) =>
  Object.entries(queryParams).every(
    ([key, value]) => query[key] === value
  );
);

Which reads like: “if there’s an entry of PAID_QUERY_PARAMS where for each key/value pair the query value for each key/value pair it matches the PAID_QUERY_PARAMS entry pair”

<script>
  (function (document, navigator, window) {
    const PAID_QUERY_PARAMS = [{ key: 'list' }, { key: 'has-paid' }];
    // Get from https://beta.observablehq.com/@hugodf/crawler-regex
    const crawlerPattern = "REPLACE_ME";
    const crawlerRegEx = new RegExp(crawlerPattern, 'i');
    const SITE_WRAPPER_SELECTOR = '.js-site-wrapper';
    const SITE_WRAPPER_NO_SCROLL_CLASS = 'js-site-wrapper--no-scroll';

    $siteWrapper = document.querySelector(SITE_WRAPPER_SELECTOR);

    function isCrawler(userAgent) {
      return crawlerRegEx.test(userAgent);
    }

    function getQueryObject(window) {
      return window.location.search
        .replace(/^\?/, "")
        .split('&')
        .map(function(keyValue) { return keyValue.split('='); })
        .reduce(function(acc, keyValue) {
          const k = keyValue[0];
          const v = keyValue[1];
          acc[k] = v;
          return acc;
        }, {});
    }

    function didBuy() {
      if (typeof navigator === "undefined" || typeof window === "undefined") {
        return true;
      }
      if (isCrawler(navigator.userAgent)) {
        return true;
      }

      const query = getQueryObject(window);
      const bought = PAID_QUERY_PARAMS.some(function (queryParams) {
        return Object.entries(queryParams).every(function(keyValue) {
          const key = keyValue[0];
          const value = keyValue[1];
          return query[key] === value;
        });
      });

      return bought;
    }
    function init() {
      if (!didBuy()) {
        // disable scroll/hide all the things
        $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
      }
    }
    init();
  })(document, navigator, window);
</script>

Persist to localstorage

We’ll want to wipe the query param and store that the user has paid to get past the paywall in localStorage. Why? Well so that the use can navigate to the page from anywhere (maybe through site navigation or something) and still see the content.

To do that we’ll need two extra bits of non-business logic-y stuff:Some new constants that we can check against:

const PAID_LOCALSTORAGE_KEY = 'MY_KEY';
const PAID_LOCALSTORAGE_VALUE = 'MY_SUPER_SECRET_VALUE';

and a tiny wrapper around window.localStorage, because why not:

const persist = {
  set: function(key, value) {
    return window.localStorage.setItem(key, value);
  },
  get: function(key) {
    return window.localStorage.getItem(key);
  }
}

Then the business logic is as follows, we’ll want to check if a HAS_PAID has been persisted in didBuy, if it hasn’t been set, fall back to checking the query object. If the query object checks out as paid, persist that. ie. in didBuy:

function didBuy() {
  // navigator and user agent (crawler) checks  
  // 1. New PAID in persistence check
  if (persist.get(PAID_LOCALSTORAGE_KEY) === PAID_LOCALSTORAGE_VALUE) {
    return true;
  }

  // 2. Existing "PAID" check
  const query = getQueryObject(window);
  const bought = PAID_QUERY_PARAMS.some(function (queryParams) {
    return Object.entries(queryParams).every(function(keyValue) {
      const key = keyValue[0];
      const value = keyValue[1];
      return query[key] === value;
    });
  });

  // 3. New persist PAID
  if (bought) {
    persist.set(PAID_LOCALSTORAGE_KEY, PAID_LOCALSTORAGE_VALUE);
  }

  return bought;
}

In full that’s:

<script>
  (function (document, navigator, window) {
    const PAID_QUERY_PARAMS = [{ key: 'list' }, { key: 'has-paid' }];
    const PAID_LOCALSTORAGE_KEY = 'MY_KEY';
    const PAID_LOCALSTORAGE_VALUE = 'MY_SUPER_SECRET_VALUE';
    // Get from https://beta.observablehq.com/@hugodf/crawler-regex
    const crawlerPattern = "REPLACE_ME";
    const crawlerRegEx = new RegExp(crawlerPattern, 'i');
    const SITE_WRAPPER_SELECTOR = '.js-site-wrapper';
    const SITE_WRAPPER_NO_SCROLL_CLASS = 'js-site-wrapper--no-scroll';

    $siteWrapper = document.querySelector(SITE_WRAPPER_SELECTOR);

    const persist = {
      set: function(key, value) {
        return window.localStorage.setItem(key, value);
      },
      get: function(key) {
        return window.localStorage.getItem(key);
      }
    }

    function isCrawler(userAgent) {
      return crawlerRegEx.test(userAgent);
    }

    function getQueryObject(window) {
      return window.location.search
        .replace(/^\?/, "")
        .split('&')
        .map(function(keyValue) { return keyValue.split('='); })
        .reduce(function(acc, keyValue) {
          const k = keyValue[0];
          const v = keyValue[1];
          acc[k] = v;
          return acc;
        }, {});
    }

    function didBuy() {
      if (typeof navigator === "undefined" || typeof window === "undefined") {
        return true;
      }
      if (isCrawler(navigator.userAgent)) {
        return true;
      }

      if (persist.get(PAID_LOCALSTORAGE_KEY) === PAID_LOCALSTORAGE_VALUE) {
        return true;
      }

      const query = getQueryObject(window);
      const bought = PAID_QUERY_PARAMS.some(function (queryParams) {
        return Object.entries(queryParams).every(function(keyValue) {
          const key = keyValue[0];
          const value = keyValue[1];
          return query[key] === value;
        });
      });

      if (bought) {
        persist.set(PAID_LOCALSTORAGE_KEY, PAID_LOCALSTORAGE_VALUE);
      }

      return bought;
    }
    function init() {
      if (!didBuy()) {
        // disable scroll/hide all the things
        $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
      }
    }
    init();
  })(document, navigator, window);
</script>

Clear the query without refreshing the page

The JS snippet to remove query parameters without a refresh using .replaceState is:

window.history.replaceState({}, document.title, window.location.pathname);

So we just stuff that in init:

function init() {
  if(!didBuy()) {
    $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
  } else {
    window.history.replaceState({}, document.title, window.location.pathname);
  }
}

The full script is therefore:

<script>
  (function (document, navigator, window) {
    const PAID_QUERY_PARAMS = [{ key: 'list' }, { key: 'has-paid' }];
    const PAID_LOCALSTORAGE_KEY = 'MY_KEY';
    const PAID_LOCALSTORAGE_VALUE = 'MY_SUPER_SECRET_VALUE';
    // Get from https://beta.observablehq.com/@hugodf/crawler-regex
    const crawlerPattern = "REPLACE_ME";
    const crawlerRegEx = new RegExp(crawlerPattern, 'i');
    const SITE_WRAPPER_SELECTOR = '.js-site-wrapper';
    const SITE_WRAPPER_NO_SCROLL_CLASS = 'js-site-wrapper--no-scroll';

    $siteWrapper = document.querySelector(SITE_WRAPPER_SELECTOR);

    const persist = {
      set: function(key, value) {
        return window.localStorage.setItem(key, value);
      },
      get: function(key) {
        return window.localStorage.getItem(key);
      }
    }

    function isCrawler(userAgent) {
      return crawlerRegEx.test(userAgent);
    }

    function getQueryObject(window) {
      return window.location.search
        .replace(/^\?/, "")
        .split('&')
        .map(function(keyValue) { return keyValue.split('='); })
        .reduce(function(acc, keyValue) {
          const k = keyValue[0];
          const v = keyValue[1];
          acc[k] = v;
          return acc;
        }, {});
    }

    function didBuy() {
      if (typeof navigator === "undefined" || typeof window === "undefined") {
        return true;
      }
      if (isCrawler(navigator.userAgent)) {
        return true;
      }

      if (persist.get(PAID_LOCALSTORAGE_KEY) === PAID_LOCALSTORAGE_VALUE) {
        return true;
      }

      const query = getQueryObject(window);
      const bought = PAID_QUERY_PARAMS.some(function (queryParams) {
        return Object.entries(queryParams).every(function(keyValue) {
          const key = keyValue[0];
          const value = keyValue[1];
          return query[key] === value;
        });
      });

      if (bought) {
        persist.set(PAID_LOCALSTORAGE_KEY, PAID_LOCALSTORAGE_VALUE);
      }

      return bought;
    }
    function init() {
      if (!didBuy()) {
        // disable scroll/hide all the things
        $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
      } else {
        window.history.replaceState({}, document.title, window.location.pathname);
      }
    }
    init();
  })(document, navigator, window);
</script>

The script in full as it is on my site

I use polyfill.io to polyfill .some, .entries and .every.I also have a “Buy Overlay”, which I toggle on using a CSS class.The rest is all explained in the article.

See it in action at Sequelize ES6 Cheatsheet.

If you like getting the cheatsheet for free but also want to support my work you can Buy me a Coffee or just subscribe to my mailing list.

<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=es6"></script>
<script>
  (function (document, navigator, window) {
    const PAID_QUERY_PARAMS = [{ key: 'list' }, { key: 'has-paid' }];
    const PAID_LOCALSTORAGE_KEY = 'MY_KEY';
    const PAID_LOCALSTORAGE_VALUE = 'MY_SUPER_SECRET_VALUE';
    // Get from https://beta.observablehq.com/@hugodf/crawler-regex
    const crawlerPattern = "REPLACE_ME";
    const crawlerRegEx = new RegExp(crawlerPattern, 'i');
    const SITE_WRAPPER_SELECTOR = '.js-site-wrapper';
    const SITE_WRAPPER_NO_SCROLL_CLASS = 'js-site-wrapper--no-scroll';
    const BUY_OVERLAY_SELECTOR = '.js-buy-overlay';
    const BUY_OVERLAY_ACTIVE_CLASS = 'js-buy-overlay--active';

    $siteWrapper = document.querySelector(SITE_WRAPPER_SELECTOR);
    $buyOverlay = document.querySelector(BUY_OVERLAY_SELECTOR);

    const persist = {
      set: function(key, value) {
        return window.localStorage.setItem(key, value);
      },
      get: function(key) {
        return window.localStorage.getItem(key);
      }
    }

    function isCrawler(userAgent) {
      return crawlerRegEx.test(userAgent);
    }

    function getQueryObject(window) {
      return window.location.search
        .replace(/^\?/, "")
        .split('&')
        .map(function(keyValue) { return keyValue.split('='); })
        .reduce(function(acc, keyValue) {
          const k = keyValue[0];
          const v = keyValue[1];
          acc[k] = v;
          return acc;
        }, {});
    }

    function didBuy () {
      if (typeof navigator === "undefined" || typeof window === "undefined") {
        return true;
      }
      if (isCrawler(navigator.userAgent)) {
        return true;
      }
      if (persist.get(PAID_LOCALSTORAGE_KEY) === PAID_LOCALSTORAGE_VALUE) {
        return true;
      }
      const query = getQueryObject(window);
      const bought = PAID_QUERY_PARAMS.some(function (queryParams) {
        return Object.entries(queryParams).every(function(keyValue) {
          const key = keyValue[0];
          const value = keyValue[1];
          return query[key] === value;
        });
      });
      if (bought) {
        persist.set(PAID_LOCALSTORAGE_KEY, PAID_LOCALSTORAGE_VALUE);
      }
      return bought;
    }

    function init() {
      if(!didBuy()) {
        $siteWrapper.classList.add(SITE_WRAPPER_NO_SCROLL_CLASS);
        $buyOverlay.classList.add(BUY_OVERLAY_ACTIVE_CLASS);
      } else {
        window.history.replaceState({}, document.title, window.location.pathname);
      }
    }
    init();
  }) (document, navigator, window);
</script>

unsplash-logo
Samuel Zeller

Top comments (1)

Collapse
 
iceorfiresite profile image
Ice or Fire

Shouldn't a paywall be instituted server side? If you aren't logged in, you can only see a preview of the article?