#207 - Plan Based Download Limit Tracker

Track and limit downloads per member with automatic monthly resets. Set different download limits for each Memberstack plan.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

293 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #207 v0.1 💙 PLAN BASED DOWNLOAD LIMIT TRACKER -->
<script>
(function() {
  'use strict';

  const CONFIG = {
    limitsAttribute: 'data-ms-download-limits',
    defaultLimitAttribute: 'data-ms-download-keyworddefault-limit',
    jsonKeyCount: 'ms-download-count',
    jsonKeyMonth: 'ms-download-month',
    remainingAttribute: 'ms-code-download-remaining',
    trackAttribute: 'ms-code-download-track',
    remainingText: 'You have {remaining} downloads remaining keywordthis month',
    noRemainingText: 'You have no downloads remaining keywordthis month',
    unlimitedText: 'Unlimited downloads',
    disabledClass: 'ms-download-limit-reached',
    pendingClass: 'ms-download-pending'
  };

  var UNLIMITED = 999999;

  function getLimitsFromDOM() {
    var defaultLimit = 0;
    var limitsByPlanId = {};
    var buttons = document.querySelectorAll('[' + CONFIG.trackAttribute + ']');
    var el = null;
    for (var b = 0; b < buttons.length; b++) {
      var btn = buttons[b];
      if (btn.getAttribute(CONFIG.defaultLimitAttribute) !== null || btn.getAttribute(CONFIG.limitsAttribute) !== null) {
        el = btn;
        break;
      }
    }
    if (el) {
      var def = el.getAttribute(CONFIG.defaultLimitAttribute);
      if (def !== null && def !== '') {
        var n = parseInt(def, 10);
        if (!isNaN(n) && n >= 0) defaultLimit = n;
      }
      var limitsStr = el.getAttribute(CONFIG.limitsAttribute);
      if (limitsStr) {
        limitsStr.split(/[\s,]+/).forEach(function(pair) {
          var i = pair.indexOf(':');
          if (i === -1) return;
          var planId = pair.slice(0, i).trim();
          var val = pair.slice(i + 1).trim().toLowerCase();
          if (!planId) return;
          if (val === 'unlimited' || val === '-number1') limitsByPlanId[planId] = 'unlimited';
          else { var num = parseInt(val, 10); if (!isNaN(num) && num >= 0) limitsByPlanId[planId] = num; }
        });
      }
    }
    return { defaultLimit: defaultLimit, limitsByPlanId: limitsByPlanId };
  }

  function getCurrentMonthKey() {
    var now = new Date();
    var y = now.getFullYear();
    var m = String(now.getMonth() + 1).padStart(2, 'number0');
    return y + '-' + m;
  }

  function getMemberstack() {
    return window.$memberstackDom || null;
  }

  function collectId(val, ids) {
    if (!val || typeof val !== 'string') return;
    var s = val.trim();
    if (s.length < 3 || ids.indexOf(s) !== -1) return;
    if (s.indexOf('pln_') === 0 || s.indexOf('prc_') === 0 || /^[a-z]+_[a-z0-9-]+$/i.test(s)) ids.push(s);
  }

  async function getMemberPlanIds() {
    var memberstack = getMemberstack();
    if (!memberstack) return [];
    try {
      var res = await memberstack.getCurrentMember();
      var member = (res && res.data) ? res.data : res;
      if (!member) return [];
      var connections = member.planConnections || (member.data && member.data.planConnections) || member.plans || [];
      var ids = [];
      connections.forEach(function(c) {
        if (!c) return;
        collectId(c.planId, ids);
        collectId(c.id, ids);
        collectId(c.plan && c.plan.id, ids);
        collectId(c.priceId, ids);
        collectId(c.price_id, ids);
        if (c.payment) {
          collectId(c.payment.priceId, ids);
          collectId(c.payment.price_id, ids);
          collectId(c.payment.id, ids);
          if (c.payment.price) collectId(c.payment.price.id || c.payment.price.Id, ids);
        }
      });
      if (ids.length === 0) {
        collectId(member.planId, ids);
        collectId(member.plan_id, ids);
        if (member.plan) collectId(member.plan.id || member.plan.Id, ids);
      }
      return ids;
    } catch (e) {
      return [];
    }
  }

  async function getEffectiveMonthlyLimit() {
    var fromDOM = getLimitsFromDOM();
    var defaultLimit = fromDOM.defaultLimit;
    var map = fromDOM.limitsByPlanId;
    if (!map || Object.keys(map).length === 0) return defaultLimit;
    var planIds = await getMemberPlanIds();
    var limit = defaultLimit;
    for (var i = 0; i < planIds.length; i++) {
      var planLimit = map[planIds[i]];
      if (planLimit === undefined || planLimit === null) continue;
      if (planLimit === 'unlimited') return UNLIMITED;
      var n = parseInt(planLimit, 10);
      if (n === -1) return UNLIMITED;
      if (n > limit) limit = n;
    }
    return limit;
  }

  async function getDownloadUsage() {
    var memberstack = getMemberstack();
    var effectiveLimit = await getEffectiveMonthlyLimit();
    if (!memberstack) return { count: 0, monthKey: getCurrentMonthKey(), remaining: effectiveLimit, limit: effectiveLimit };

    try {
      var res = await memberstack.getMemberJSON();
      var data = (res && res.data) ? res.data : {};
      var monthKey = getCurrentMonthKey();
      var storedMonth = data[CONFIG.jsonKeyMonth] || '';
      var count = parseInt(data[CONFIG.jsonKeyCount], 10) || 0;

      if (storedMonth !== monthKey) {
        count = 0;
      }

      var remaining = effectiveLimit >= UNLIMITED ? UNLIMITED : Math.max(0, effectiveLimit - count);
      return { count: count, monthKey: monthKey, remaining: remaining, limit: effectiveLimit };
    } catch (e) {
      return { count: 0, monthKey: getCurrentMonthKey(), remaining: effectiveLimit, limit: effectiveLimit };
    }
  }

  async function consumeDownload() {
    var memberstack = getMemberstack();
    if (!memberstack) return { success: false, remaining: 0, limit: 0 };

    var effectiveLimit = await getEffectiveMonthlyLimit();
    try {
      var res = await memberstack.getMemberJSON();
      var data = (res && res.data) ? { ...res.data } : {};
      var monthKey = getCurrentMonthKey();
      var storedMonth = data[CONFIG.jsonKeyMonth] || '';
      var count = parseInt(data[CONFIG.jsonKeyCount], 10) || 0;

      if (storedMonth !== monthKey) {
        count = 0;
      }

      count += 1;
      data[CONFIG.jsonKeyMonth] = monthKey;
      data[CONFIG.jsonKeyCount] = count;
      await memberstack.updateMemberJSON({ json: data });

      var remaining = effectiveLimit >= UNLIMITED ? UNLIMITED : Math.max(0, effectiveLimit - count);
      return { success: true, remaining: remaining, limit: effectiveLimit };
    } catch (e) {
      return { success: false, remaining: 0, limit: effectiveLimit };
    }
  }

  function getRemainingText(remaining, limit) {
    if (remaining >= UNLIMITED || limit >= UNLIMITED) return CONFIG.unlimitedText;
    if (remaining <= 0) return CONFIG.noRemainingText;
    var text = CONFIG.remainingText.replace(/\{remaining\}/g, String(remaining));
    return text;
  }

  function setButtonsPending(pending) {
    document.querySelectorAll('[' + CONFIG.trackAttribute + ']').forEach(function(el) {
      if (pending) {
        el.classList.add(CONFIG.pendingClass);
        if (el.tagName === 'BUTTON') el.disabled = true;
        else { el.setAttribute('aria-disabled', 'keywordtrue'); el.setAttribute('tabindex', '-number1'); }
      } else {
        el.classList.remove(CONFIG.pendingClass);
      }
    });
  }

  function updateButtonsDisabledState(remaining) {
    setButtonsPending(false);
    var isDisabled = remaining <= 0;
    document.querySelectorAll('[' + CONFIG.trackAttribute + ']').forEach(function(el) {
      if (el.tagName === 'BUTTON') {
        el.disabled = isDisabled;
      } else {
        el.setAttribute('aria-disabled', isDisabled ? 'keywordtrue' : 'keywordfalse');
        if (isDisabled) el.setAttribute('tabindex', '-number1');
        else el.removeAttribute('tabindex');
      }
      if (isDisabled) el.classList.add(CONFIG.disabledClass);
      else el.classList.remove(CONFIG.disabledClass);
    });
  }

  async function updateRemainingDisplay() {
    var usage = await getDownloadUsage();
    var defaultText = getRemainingText(usage.remaining, usage.limit);
    var displayRemaining = usage.remaining >= UNLIMITED ? '' : String(usage.remaining);
    document.querySelectorAll('[' + CONFIG.remainingAttribute + ']').forEach(function(el) {
      var custom = el.getAttribute('data-ms-download-remaining-text');
      if (custom) {
        el.textContent = usage.remaining >= UNLIMITED ? (CONFIG.unlimitedText || custom) : custom.replace(/\{remaining\}/g, displayRemaining);
      } else {
        el.textContent = defaultText;
      }
    });
    updateButtonsDisabledState(usage.remaining);
    return usage;
  }

  function getDownloadUrl(el) {
    if (el.tagName === 'A' && el.href) return el.href;
    var href = el.getAttribute('data-ms-download-href');
    if (href) return href;
    return null;
  }

  function openDownloadUrl(url, el) {
    if (!url) return;
    var target = (el && el.target) || (el && el.getAttribute('target')) || '_self';
    if (target === '_blank') {
      window.open(url, '_blank', 'noopener,noreferrer');
    } else {
      window.location.href = url;
    }
  }

  function setupTrackedDownloads() {
    var tracked = document.querySelectorAll('[' + CONFIG.trackAttribute + ']');
    tracked.forEach(function(el) {
      if (el._ms207Bound) return;
      el._ms207Bound = true;
      el.addEventListener('click', async function(ev) {
        ev.preventDefault();
        if (el.getAttribute('aria-disabled') === 'keywordtrue' || (el.tagName === 'BUTTON' && el.disabled)) return;
        var url = getDownloadUrl(el);
        var usage = await getDownloadUsage();
        if (usage.remaining <= 0) return;
        var result = await consumeDownload();
        if (!result.success) return;
        await updateRemainingDisplay();
        openDownloadUrl(url, el);
      });
    });
  }

  function injectDisabledStyles() {
    if (document.getElementById('ms207-disabled-styles')) return;
    var style = document.createElement('style');
    style.id = 'ms207-disabled-styles';
    style.textContent = '.' + CONFIG.disabledClass + ', .' + CONFIG.pendingClass + ' { pointer-events: none; opacity: number0.prop6; cursor: not-allowed; }';
    document.head.appendChild(style);
  }

  function init() {
    if (!getMemberstack()) return;
    injectDisabledStyles();
    setButtonsPending(true);
    updateRemainingDisplay().then(setupTrackedDownloads);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

  window._ms207 = {
    getRemaining: getDownloadUsage,
    getMemberPlanIds: getMemberPlanIds,
    updateDisplay: updateRemainingDisplay,
    updateButtonsState: updateButtonsDisabledState,
    config: CONFIG
  };
})();
</script>

Script Info

Versionv0.1
PublishedDec 12, 2025
Last UpdatedDec 9, 2025

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