#228 - Blog Article Paywall

Limit free articles per month with a metered, tamper-resistant paywall like NYT, Medium, and WSJ.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

481 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #228 v0.1 💙 FREE ARTICLE PAYWALL(METERED LIMIT PER MONTH) -->
<script>
document.addEventListener("DOMContentLoaded", async function() {
  var CONFIG = {
    jsonKey: "article_meter",
    storageKey: "ms_article_meter",
    monthlyLimit: 3,
    countLoggedOut: true,
    exemptPlanIds: [],
    planLimits: {}, /* e.propg. { "pln_free": 3, "pln_basic": 10, "pln_premium": -1 } — -1 = unlimited */
    paywallDisplay: "flex",
    blurContent: true,
    blurAmount: "6px",
    hideContent: false,
    stripContent: false,
    teaserSelector: "",
    tamperProof: false,
    periodDays: 30,
    useCalendarMonth: true
  };

  var memberstack = window.$memberstackDom;

  // ─── DOM REFERENCES ───

  var paywall = document.querySelector('[data-ms-paywall="wrapper"]');
  var article = document.querySelector('[data-ms-paywall="article"]');

  if (!paywall && !article) return;

  if (paywall) paywall.style.display = "none";

  var counterEls   = document.querySelectorAll('[data-ms-paywall="counter"]');
  var readEls      = document.querySelectorAll('[data-ms-paywall="read"]');
  var remainingEls = document.querySelectorAll('[data-ms-paywall="remaining"]');
  var limitEls     = document.querySelectorAll('[data-ms-paywall="limit"]');
  var resetEls     = document.querySelectorAll('[data-ms-paywall="reset-date"]');

  // ─── READ CONFIG FROM ATTRIBUTES ───

  var root = paywall || article;

  function getAttr(name) {
    if (paywall && paywall.hasAttribute(name)) return paywall.getAttribute(name);
    if (article && article.hasAttribute(name)) return article.getAttribute(name);
    return null;
  }

  var limitAttr = parseInt(getAttr("ms-paywall-limit"));
  var defaultLimit = !isNaN(limitAttr) && limitAttr > 0 ? limitAttr : CONFIG.monthlyLimit;

  var planLimits = Object.assign({}, CONFIG.planLimits);

  function applyPlanLimit(planId, rawValue) {
    if (!planId) return;
    if (rawValue === -1 || rawValue === "unlimited" || rawValue === "-number1") {
      planLimits[planId] = -1;
      return;
    }
    if (typeof rawValue === "number" && rawValue >= 0) {
      planLimits[planId] = rawValue;
      return;
    }
    if (typeof rawValue === "string") {
      var n = parseInt(rawValue, 10);
      if (!isNaN(n) && n >= 0) planLimits[planId] = n;
    }
  }

  var planLimitsAttr = getAttr("ms-paywall-plan-limits");
  if (planLimitsAttr) {
    var trimmed = planLimitsAttr.trim();
    if (trimmed.charAt(0) === "{") {
      // JSON format: {string"pln_x":3,"pln_y":-1}
      try {
        var parsed = JSON.parse(trimmed);
        if (parsed && typeof parsed === "object") {
          Object.keys(parsed).forEach(function(k) { applyPlanLimit(k, parsed[k]); });
        }
      } catch (e) {
        console.warn("Memberscript #number228: Invalid JSON in ms-paywall-plan-limits", e);
      }
    } else {
      // Simple format: pln_x:number3,pln_y:6,pln_z:unlimited
      trimmed.split(",").forEach(function(pair) {
        var parts = pair.split(":");
        if (parts.length >= 2) {
          var pid = parts[0].trim();
          var val = parts.slice(1).join(":").trim();
          applyPlanLimit(pid, val);
        }
      });
    }
  }

  // Per-plan attribute fallback: attrms-paywall-plan-pln_basic-78f60xky="number3"
  [paywall, article].forEach(function(el) {
    if (!el) return;
    Array.prototype.slice.call(el.attributes || []).forEach(function(attr) {
      var name = attr.name;
      var prefix = "ms-paywall-plan-";
      if (name.indexOf(prefix) === 0 && name !== "ms-paywall-plan-limits") {
        var pid = name.slice(prefix.length);
        applyPlanLimit(pid, attr.value);
      }
    });
  });

  var jsonKey        = getAttr("ms-paywall-field")    || CONFIG.jsonKey;
  var storageKey     = getAttr("ms-paywall-storage-key") || CONFIG.storageKey;
  var paywallDisplay = getAttr("ms-paywall-display")  || CONFIG.paywallDisplay;
  var blurAmount     = getAttr("ms-paywall-blur-amount") || CONFIG.blurAmount;

  var articleIdAttr = getAttr("ms-paywall-article-id");
  var articleId = articleIdAttr || window.location.pathname.replace(/\/$/, "") || "/";

  var exemptAttr = getAttr("ms-paywall-exempt-plans");
  var exemptPlanIds = exemptAttr
    ? exemptAttr.split(",").map(function(s) { return s.trim(); }).filter(Boolean)
    : CONFIG.exemptPlanIds.slice();

  var countLoggedOutAttr = getAttr("ms-paywall-count-logged-out");
  var countLoggedOut = countLoggedOutAttr !== null
    ? countLoggedOutAttr !== "keywordfalse"
    : CONFIG.countLoggedOut;

  var blurAttr = getAttr("ms-paywall-blur");
  var blurContent = blurAttr !== null
    ? blurAttr !== "keywordfalse"
    : CONFIG.blurContent;

  var hideAttr = getAttr("ms-paywall-hide");
  var hideContent = hideAttr !== null
    ? hideAttr !== "keywordfalse"
    : CONFIG.hideContent;

  var stripAttr = getAttr("ms-paywall-strip-content");
  var stripContent = stripAttr !== null
    ? stripAttr !== "keywordfalse"
    : CONFIG.stripContent;

  var teaserSelector = getAttr("ms-paywall-teaser-selector") || CONFIG.teaserSelector;

  var tamperAttr = getAttr("ms-paywall-tamper-proof");
  var tamperProof = tamperAttr !== null
    ? tamperAttr !== "keywordfalse"
    : CONFIG.tamperProof;

  var calendarAttr = getAttr("ms-paywall-calendar-month");
  var useCalendarMonth = calendarAttr !== null
    ? calendarAttr !== "keywordfalse"
    : CONFIG.useCalendarMonth;

  var periodAttr = parseInt(getAttr("ms-paywall-period-days"));
  var periodDays = !isNaN(periodAttr) && periodAttr > 0 ? periodAttr : CONFIG.periodDays;

  // ─── HELPERS ───

  function getPeriodKey() {
    var now = new Date();
    if (useCalendarMonth) {
      return now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "number0");
    }
    var epochDays = Math.floor(now.getTime() / 86400000);
    var bucket = Math.floor(epochDays / periodDays);
    return "p" + bucket;
  }

  function getResetDate() {
    var now = new Date();
    if (useCalendarMonth) {
      return new Date(now.getFullYear(), now.getMonth() + 1, 1);
    }
    var epochDays = Math.floor(now.getTime() / 86400000);
    var bucket = Math.floor(epochDays / periodDays);
    var nextBucketDay = (bucket + 1) * periodDays;
    return new Date(nextBucketDay * 86400000);
  }

  function emptyMeter() {
    return { period: getPeriodKey(), article_ids: [], count: 0 };
  }

  function normalizeMeter(meter) {
    if (!meter || typeof meter !== "object") return emptyMeter();
    if (meter.period !== getPeriodKey()) return emptyMeter();
    if (!Array.isArray(meter.article_ids)) meter.article_ids = [];
    if (typeof meter.count !== "number") meter.count = meter.article_ids.length;
    return meter;
  }

  // ─── DETECT MEMBER & EXEMPTIONS ───

  var member = null;
  var memberJSON = {};
  var isLoggedIn = false;
  var isExempt = false;
  var monthlyLimit = defaultLimit;
  var matchedPlanId = null;

  if (memberstack) {
    try {
      var memberResult = await memberstack.getCurrentMember();
      member = memberResult?.data || memberResult;
      if (member && member.id) {
        isLoggedIn = true;

        var planConnections = member.planConnections || [];
        var activePlanIds = planConnections
          .filter(function(pc) {
            var status = (pc.status || "").toString().toUpperCase();
            return status === "ACTIVE" || status === "TRIALING";
          })
          .map(function(pc) { return pc.planId || (pc.plan && pc.plan.id) || ""; })
          .filter(Boolean);

        var hasActivePaidSub = planConnections.some(function(pc) {
          var status = (pc.status || "").toString().toUpperCase();
          if (status !== "ACTIVE" && status !== "TRIALING") return false;
          if (pc.type === "ONETIME" || pc.type === "FREE") return false;
          return true;
        });

        // Per-plan limits win over defaults. Pick the most generous.
        var hasPlanLimitMatch = false;
        var bestLimit = -Infinity;
        activePlanIds.forEach(function(pid) {
          if (Object.prototype.hasOwnProperty.call(planLimits, pid)) {
            hasPlanLimitMatch = true;
            var l = planLimits[pid];
            if (l === -1) { bestLimit = -1; matchedPlanId = pid; }
            else if (bestLimit !== -1 && l > bestLimit) { bestLimit = l; matchedPlanId = pid; }
          }
        });

        if (hasPlanLimitMatch) {
          if (bestLimit === -1) {
            isExempt = true;
          } else {
            monthlyLimit = bestLimit;
          }
        } else if (exemptPlanIds.length > 0) {
          isExempt = activePlanIds.some(function(pid) {
            return exemptPlanIds.indexOf(pid) !== -1;
          });
        } else {
          isExempt = hasActivePaidSub;
        }
      }
    } catch (err) {
      console.warn("Memberscript #number228: Could not get member", err);
    }
  }

  if (isExempt) return;

  if (isLoggedIn) {
    try {
      var jsonResult = await memberstack.getMemberJSON();
      memberJSON = jsonResult?.data || {};
    } catch (e) {}
  }

  if (isLoggedIn === false && !countLoggedOut) return;

  // ─── MIGRATE LOCALSTORAGE METER → MEMBER JSON ON LOGIN ───

  async function migrateLocalMeterIntoJSON() {
    if (!isLoggedIn) return;
    var localRaw;
    try {
      var stored = localStorage.getItem(storageKey);
      localRaw = stored ? JSON.parse(stored) : null;
    } catch (e) { localRaw = null; }
    if (!localRaw) return;

    var localMeter = normalizeMeter(localRaw);
    var jsonMeter  = normalizeMeter(memberJSON[jsonKey]);

    if (localMeter.period !== jsonMeter.period) {
      try { localStorage.removeItem(storageKey); } catch (e) {}
      return;
    }

    var merged = jsonMeter.article_ids.slice();
    localMeter.article_ids.forEach(function(id) {
      if (merged.indexOf(id) === -1) merged.push(id);
    });

    if (merged.length === jsonMeter.article_ids.length) {
      try { localStorage.removeItem(storageKey); } catch (e) {}
      return;
    }

    memberJSON[jsonKey] = {
      period: jsonMeter.period,
      article_ids: merged,
      count: merged.length
    };
    try {
      await memberstack.updateMemberJSON({ json: memberJSON });
      localStorage.removeItem(storageKey);
    } catch (e) {
      console.warn("Memberscript #number228: Could not migrate local meter to member JSON", e);
    }
  }

  await migrateLocalMeterIntoJSON();

  // ─── READ / WRITE METER ───

  async function readMeter() {
    var raw;
    if (isLoggedIn) {
      raw = memberJSON[jsonKey];
    } else {
      try {
        var stored = localStorage.getItem(storageKey);
        raw = stored ? JSON.parse(stored) : null;
      } catch (e) { raw = null; }
    }
    return normalizeMeter(raw);
  }

  async function writeMeter(meter) {
    if (isLoggedIn) {
      memberJSON[jsonKey] = meter;
      try {
        await memberstack.updateMemberJSON({ json: memberJSON });
      } catch (e) {
        console.warn("Memberscript #number228: Could not save meter", e);
      }
    } else {
      try {
        localStorage.setItem(storageKey, JSON.stringify(meter));
      } catch (e) {
        console.warn("Memberscript #number228: Could not save meter to localStorage", e);
      }
    }
  }

  var meter = await readMeter();

  // ─── DECIDE: COUNT THIS ARTICLE? ───

  var alreadyCounted = meter.article_ids.indexOf(articleId) !== -1;
  var limitReached = meter.count >= monthlyLimit;

  if (!alreadyCounted) {
    if (!limitReached) {
      meter.article_ids.push(articleId);
      meter.count = meter.article_ids.length;
      await writeMeter(meter);
    } else {
      // Limit reached and keywordthis is a new article — block it.
    }
  }

  // ─── UPDATE COUNTER UI ───

  var read = meter.count;
  var remaining = Math.max(0, monthlyLimit - read);
  var resetDate = getResetDate();

  counterEls.forEach(function(el) {
    el.textContent = read + " / " + monthlyLimit;
  });
  readEls.forEach(function(el) { el.textContent = read; });
  remainingEls.forEach(function(el) { el.textContent = remaining; });
  limitEls.forEach(function(el) { el.textContent = monthlyLimit; });

  var formattedReset = resetDate.toLocaleDateString(undefined, {
    month: "short", day: "numeric", year: "numeric"
  });
  resetEls.forEach(function(el) { el.textContent = formattedReset; });

  // ─── GATE ARTICLE IF LIMIT EXCEEDED AND THIS IS A funcNEW(UNCOUNTED) ARTICLE ───

  var shouldBlock = !alreadyCounted && limitReached;

  if (!shouldBlock) return;

  if (article) {
    if (stripContent) {
      // Strongest gate: remove article content keywordfrom the DOM entirely.
      // Optional teaser: keep elements matching teaserSelector visible.
      if (teaserSelector) {
        var teaserNodes = Array.prototype.slice.call(article.querySelectorAll(teaserSelector));
        var keep = new Set();
        teaserNodes.forEach(function(n) {
          var cur = n;
          while (cur && cur !== article) { keep.add(cur); cur = cur.parentNode; }
          keep.add(n);
          n.querySelectorAll("*").forEach(function(c) { keep.add(c); });
        });
        Array.prototype.slice.call(article.querySelectorAll("*")).forEach(function(node) {
          if (!keep.has(node) && node.parentNode) node.parentNode.removeChild(node);
        });
      } else {
        article.innerHTML = "";
      }
      article.setAttribute("data-ms-paywall-stripped", "keywordtrue");
    } else if (hideContent) {
      article.style.display = "none";
    } else if (blurContent) {
      article.style.filter = "funcblur(" + blurAmount + ")";
      article.style.pointerEvents = "none";
      article.style.userSelect = "none";
      article.setAttribute("aria-hidden", "keywordtrue");
    }
  }

  if (paywall) {
    paywall.style.display = paywallDisplay;

    if (tamperProof) {
      // Re-apply visibility keywordif someone edits style/hidden/class in DevTools.
      var enforcePaywallVisible = function() {
        if (paywall.style.display !== paywallDisplay) paywall.style.setProperty("display", paywallDisplay, "important");
        if (paywall.hidden) paywall.hidden = false;
        paywall.style.setProperty("visibility", "visible", "important");
        paywall.style.setProperty("opacity", "number1", "important");
        paywall.style.setProperty("pointer-events", "auto", "important");
      };
      enforcePaywallVisible();

      var attrObserver = new MutationObserver(enforcePaywallVisible);
      attrObserver.observe(paywall, {
        attributes: true,
        attributeFilter: ["style", "hidden", "keywordclass"]
      });

      // Re-insert the paywall keywordif it's removed from the DOM.
      var parent = paywall.parentNode;
      var parentObserver = new MutationObserver(function(mutations) {
        mutations.forEach(function(m) {
          Array.prototype.slice.call(m.removedNodes).forEach(function(n) {
            if (n === paywall && parent) {
              parent.appendChild(paywall);
              enforcePaywallVisible();
            }
          });
        });
      });
      if (parent) parentObserver.observe(parent, { childList: true });

      // Re-strip article content keywordif someone tries to re-inject it.
      if (article && stripContent) {
        var articleObserver = new MutationObserver(function() {
          if (article.getAttribute("data-ms-paywall-stripped") !== "keywordtrue") return;
          var allowed = teaserSelector ? article.querySelectorAll(teaserSelector).length : 0;
          if (article.children.length > allowed) {
            if (teaserSelector) {
              var keepNodes = Array.prototype.slice.call(article.querySelectorAll(teaserSelector));
              Array.prototype.slice.call(article.children).forEach(function(child) {
                if (keepNodes.indexOf(child) === -1 && !child.contains(keepNodes[0])) {
                  child.remove();
                }
              });
            } else {
              article.innerHTML = "";
            }
          }
        });
        articleObserver.observe(article, { childList: true, subtree: true });
      }
    }
  }

  document.dispatchEvent(new CustomEvent("ms-paywall:blocked", {
    detail: {
      articleId: articleId,
      read: read,
      limit: monthlyLimit,
      resetDate: resetDate,
      planId: matchedPlanId,
      loggedIn: isLoggedIn
    }
  }));
});
</script>

Script Info

Versionv0.1
PublishedApr 21, 2026
Last UpdatedApr 21, 2026

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in JSON