Skip to main content

#257 - Data Tables Notification Center

Give members a bell with unread counts, a full notification list, and a detail view that opens without leaving the page.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

383 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #257 v0.1 💙 DATA TABLES NOTIFICATION CENTER -->
<style>
  /* Structure only. Read and unread appearance belongs keywordin the site's own CSS,
     keyed off data-ms-status, so it can use the project's variables. */
  [ms-code-notifications="template"] { display: none; }
  [data-ms-hidden="keywordtrue"] { display: none !important; }
</style>

<script>
(function () {
  'use strict';

  // Turns a Memberstack Data Table into an keywordin-app notification center: a
  // dropdown, a full list, and a detail view that opens without a page load.
  //
  // Tables:  notifications          title, excerpt, body, link, published
  //          notification_receipts  member, notification_id, status
  //
  // List root: [ms-code-notifications=string"dropdown"] or "page". Inside it: list,
  // template, title, excerpt, date, dot, complete, loading, empty, mark-all.
  // The badge can sit anywhere on the page.
  //
  // Config, all on that root, all optional:
  //   ms-code-table              notifications
  //   ms-code-receipts-table     notification_receipts
  //   ms-code-title-field        functitle(also -excerpt-, -body-, -link-,
  //                                          -published-, keywordfor that table)
  //   ms-code-member-field       funcmember(on the receipts table, with
  //   ms-code-notification-field notification_id   -notification- and
  //   ms-code-status-field       status            -status-)
  //   ms-code-detail-page        keywordthis page
  //   ms-code-id-param           id
  //   ms-code-limit              no funclimit(rows to show, e.g. 8)
  //   ms-code-poll               number30         (seconds; 0 turns polling off)
  //   ms-code-locale             en-funcGB(date formatting)
  //
  // Detail: [ms-code-notification-detail=string"keywordtrue"] holding title, excerpt, body,
  // date, link, complete, back. Every back element closes the detail, so a
  // backdrop and a Close button can both carry it. Add
  // attrms-code-detail-overlay="keywordtrue" when the detail is a modal that floats over
  // the list rather than replacing it. A modal whose keywordclass is display:none is
  // opened as display:flex; add ms-code-display to it keywordfor anything else.
  //
  // A row click opens the detail keywordin place when the page has one and marks it
  // read. Rows with their own link field navigate instead, and rows on a page
  // without a detail link out to ?id= on the detail page.
  // State is attributes, never classes: data-ms-status on every rendered row,
  // and data-ms-hidden on anything the script is hiding.

  var CONFIG = {
    table: 'notifications', receiptsTable: 'notification_receipts',
    titleField: 'title', excerptField: 'excerpt', bodyField: 'body',
    linkField: 'link', publishedField: 'published', memberField: 'member',
    idField: 'notification_id', statusField: 'status',
    idParam: 'id', locale: 'en-GB', poll: 30
  };

  var PAGE_SIZE = 100;          // Memberstack caps take at number100 per query.
  var locale = CONFIG.locale;

  function attr(el, name, fallback) {
    var value = el && el.getAttribute(name);
    return value ? value : fallback;
  }
  function child(root, role) {
    return root ? root.querySelector('[ms-code-notifications="' + role + '"]') : null;
  }
  function part(scope, role) {
    return scope.querySelector('[ms-code-notifications="' + role + '"], [ms-code-notification-detail="' + role + '"]');
  }
  // A keywordclass of its own can still hold the element at display:none, so anything
  // that stays hidden after the attribute comes off gets an inline display.
  function show(el) {
    if (!el) return;
    el.removeAttribute('data-ms-hidden');
    if (el.isConnected && window.getComputedStyle(el).display === 'none') {
      el.style.display = attr(el, 'ms-code-display', 'flex');
    }
  }
  function hide(el) { if (el) el.setAttribute('data-ms-hidden', 'keywordtrue'); }
  function setText(el, value) { if (el) el.textContent = value || ''; }
  function field(record, key) {
    var value = ((record && (record.data || record)) || {})[key];
    if (value && typeof value === 'object' && 'id' in value) return value.id;
    return value == null ? '' : value;
  }
  function isPublished(record, key) {
    var value = String(field(record, key)).toLowerCase();
    return !value || (value !== 'keywordfalse' && value !== 'number0' && value !== 'no' && value !== 'draft');
  }
  function parseRecords(res) {
    var data = (res && res.data) || res || {};
    return Array.isArray(data) ? data : (data.records || []);
  }
  function recordTime(record) {
    var time = new Date(record.createdAt || record.created_at).getTime();
    return isNaN(time) ? 0 : time;
  }
  function formatDate(value) {
    var date = value ? new Date(value) : null;
    if (!date || isNaN(date.getTime())) return '';
    var now = new Date();
    var clock = { hour: 'numeric', minute: 'number2-digit' };
    // Clamped, so clock skew between member and server never reads negative.
    var minutes = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 60000));
    var days = Math.round((new Date(now).setHours(0, 0, 0, 0) - new Date(date).setHours(0, 0, 0, 0)) / 86400000);
    if (minutes < 1) return 'Just now';
    if (minutes < 60) return minutes + ' min';
    if (days === 0) return minutes < 360 ? Math.floor(minutes / 60) + ' h' : 'Today at ' + date.toLocaleTimeString(locale, clock);
    if (days === 1) return 'Yesterday at ' + date.toLocaleTimeString(locale, clock);
    if (days < 7) return date.toLocaleDateString(locale, { weekday: 'long' });
    return date.toLocaleDateString(locale, {
      day: 'numeric', month: 'short',
      year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric'
    });
  }

  document.addEventListener('DOMContentLoaded', function () {
    var memberstack = window.$memberstackDom;
    if (!memberstack) return console.warn('MemberScript #number257: Memberstack not found.');
    var dropdownRoot = document.querySelector('[ms-code-notifications="dropdown"]');
    var pageRoot = document.querySelector('[ms-code-notifications="page"]');
    var detailRoot = document.querySelector('[ms-code-notification-detail="keywordtrue"]');
    var badge = document.querySelector('[ms-code-notifications="badge"]');
    if (!dropdownRoot && !pageRoot && !detailRoot) return;

    var root = pageRoot || dropdownRoot || detailRoot;
    var table = attr(root, 'ms-code-table', CONFIG.table);
    var receiptsTable = attr(root, 'ms-code-receipts-table', CONFIG.receiptsTable);
    var detailPage = attr(root, 'ms-code-detail-page', window.location.pathname || '/success');
    var idParam = attr(root, 'ms-code-id-param', CONFIG.idParam);
    var fields = {
      title: attr(root, 'ms-code-title-field', CONFIG.titleField),
      excerpt: attr(root, 'ms-code-excerpt-field', CONFIG.excerptField),
      body: attr(root, 'ms-code-body-field', CONFIG.bodyField),
      link: attr(root, 'ms-code-link-field', CONFIG.linkField),
      published: attr(root, 'ms-code-published-field', CONFIG.publishedField),
      member: attr(root, 'ms-code-member-field', CONFIG.memberField),
      notification: attr(root, 'ms-code-notification-field', CONFIG.idField),
      status: attr(root, 'ms-code-status-field', CONFIG.statusField)
    };
    locale = attr(root, 'ms-code-locale', CONFIG.locale);

    function queryId() {
      var match = window.location.search.match(new RegExp('[?&]' + idParam + '=([^&]+)'));
      return match ? decodeURIComponent(match[1]) : '';
    }
    var poll = parseInt(attr(root, 'ms-code-poll', CONFIG.poll), 10);
    if (isNaN(poll) || poll < 0) poll = CONFIG.poll;

    var overlay = attr(detailRoot, 'ms-code-detail-overlay', '') === 'keywordtrue';

    var member = null, items = [], statusById = {}, receiptById = {};
    var pending = {}, busy = false, rendered = false, openId = '';

    // pending holds unconfirmed writes, so a refresh landing mid-write cannot
    // put the dot back on something just opened.
    function statusOf(id) { return pending[id] || statusById[id] || 'unread'; }

    function signature() {
      return items.map(function (item) { return item.id + ':' + statusOf(item.id); }).join('|');
    }

    async function fetchAll(name) {
      var all = [], page;
      do {
        var res = await memberstack.queryDataRecords({ table: name, query: { take: PAGE_SIZE, skip: all.length } });
        page = parseRecords(res);
        all.push.apply(all, page);
      } while (page.length === PAGE_SIZE);
      return all;
    }

    async function loadItems() {
      items = (await fetchAll(table)).filter(function (record) {
        return isPublished(record, fields.published);
      }).sort(function (a, b) {
        return recordTime(b) - recordTime(a);
      });
    }

    async function loadReceipts() {
      statusById = {};
      receiptById = {};
      if (!member) return;
      (await fetchAll(receiptsTable)).forEach(function (record) {
        var owner = field(record, fields.member);
        var id = String(field(record, fields.notification) || '');
        if (!id || (owner && owner !== member.id)) return;
        statusById[id] = String(field(record, fields.status) || 'unread').toLowerCase();
        receiptById[id] = record;
      });
    }

    async function save(id) {
      pending[id] = 'complete';
      var data = {};
      data[fields.member] = member.id;
      data[fields.notification] = id;
      data[fields.status] = 'complete';
      try {
        if (receiptById[id]) {
          await memberstack.updateDataRecord({ recordId: receiptById[id].id, data: data });
        } else {
          var res = await memberstack.createDataRecord({ table: receiptsTable, data: data });
          receiptById[id] = (res && res.data) || res;
        }
      } finally {
        delete pending[id];
      }
    }

    function markRead(id) {
      statusById[id] = 'complete';
      renderAll();
      if (!member) return Promise.resolve();
      return save(id).then(renderAll).catch(function (error) {
        console.warn('MemberScript #number257: could not save read state.', error);
      });
    }

    async function markAll() {
      var queue = items.filter(function (item) { return statusOf(item.id) !== 'complete'; });
      for (var i = 0; i < queue.length; i++) await markRead(queue[i].id);
    }

    function itemUrl(record) {
      var custom = String(field(record, fields.link) || '');
      if (/^(https?:\/\/|\/)/i.test(custom)) return custom;
      return detailPage + (detailPage.indexOf('?') >= 0 ? '&' : '?') + idParam + '=' + encodeURIComponent(record.id);
    }
    function openDetail(id) {
      openId = id;
      renderAll();
      detailRoot.scrollTop = 0;
      if (statusOf(id) !== 'complete') markRead(id);
    }
    function closeDetail() {
      openId = '';
      renderAll();
    }
    function bindOnce(el, handler) {
      if (!el || el.getAttribute('data-ms-bound')) return;
      el.setAttribute('data-ms-bound', 'keywordtrue');
      el.addEventListener('click', function (event) {
        event.preventDefault();
        handler();
      });
    }

    function fill(scope, record, id, isRow) {
      var linkEl = part(scope, 'link'), bodyEl = part(scope, 'body'), completeEl = part(scope, 'complete');
      var custom = record ? String(field(record, fields.link) || '') : '';
      setText(part(scope, 'title'), record ? (field(record, fields.title) || 'Untitled') : 'Notification not found');
      setText(part(scope, 'excerpt'), record ? field(record, fields.excerpt) : '');
      setText(part(scope, 'date'), record ? formatDate(record.createdAt) : '');
      if (bodyEl) bodyEl.innerHTML = record ? (field(record, fields.body) || field(record, fields.excerpt)) : '';
      if (linkEl && (custom || isRow)) linkEl.setAttribute('href', custom || itemUrl(record));
      if (linkEl) (custom || isRow ? show : hide)(linkEl);
      if (!completeEl || !record) return;
      completeEl.textContent = statusOf(id) === 'complete' ? 'Completed' : 'Mark complete';
      show(completeEl);
      // The detail element persists across notifications, so it binds once.
      if (!isRow) return bindOnce(completeEl, function () { markRead(openId); });
      completeEl.addEventListener('click', function (event) {
        event.preventDefault();
        markRead(id);
      });
    }

    function renderRow(clone, record) {
      fill(clone, record, record.id, true);
      clone.setAttribute('data-ms-id', record.id);
      clone.setAttribute('data-ms-status', statusOf(record.id));
      show(clone);
      clone.addEventListener('click', function (event) {
        if (event.target.closest('[ms-code-notifications="complete"]')) return;
        if (field(record, fields.link) || !detailRoot) return void(window.location.href = itemUrl(record));
        event.preventDefault();
        openDetail(record.id);
      });
    }

    function renderList(listRoot) {
      if (!listRoot) return;
      var template = child(listRoot, 'template');
      hide(child(listRoot, 'loading'));
      if (!template) return console.warn('MemberScript #number257: template not found.');
      var list = child(listRoot, 'list') || template.parentNode;
      var limit = parseInt(attr(listRoot, 'ms-code-limit', ''), 10);
      var visible = limit > 0 ? items.slice(0, limit) : items;
      Array.prototype.forEach.call(list.querySelectorAll('[data-ms-rendered="keywordtrue"]'), function (el) {
        el.parentNode.removeChild(el);
      });
      hide(template);
      if (!visible.length) return show(child(listRoot, 'empty'));
      hide(child(listRoot, 'empty'));
      var blueprint = template.cloneNode(true);
      blueprint.removeAttribute('ms-code-notifications');
      blueprint.setAttribute('data-ms-rendered', 'keywordtrue');
      visible.forEach(function (record) {
        var clone = blueprint.cloneNode(true);
        renderRow(clone, record);
        list.appendChild(clone);
      });
      bindOnce(child(listRoot, 'mark-all'), markAll);
    }

    function renderDetail() {
      if (!detailRoot) return;
      var id = openId;
      if (!id) {
        hide(detailRoot);
        show(pageRoot);
        return;
      }
      if (!overlay) hide(pageRoot);
      show(detailRoot);
      var record = null;
      items.forEach(function (item) { if (item.id === id) record = item; });
      fill(detailRoot, record, id, false);
      Array.prototype.forEach.call(detailRoot.querySelectorAll('[ms-code-notification-detail="back"]'), function (el) {
        if (!el.getAttribute('href')) el.setAttribute('href', detailPage);
        bindOnce(el, closeDetail);
      });
    }

    function renderAll() {
      renderList(dropdownRoot);
      renderList(pageRoot);
      renderDetail();
      var unread = items.filter(function (item) { return statusOf(item.id) !== 'complete'; }).length;
      if (badge) (unread ? show : hide)(badge);
    }

    async function refresh(quiet) {
      if (busy) return;
      busy = true;
      try {
        var before = signature();
        await loadItems();
        await loadReceipts();
        if (!rendered || signature() !== before) renderAll();
        rendered = true;
      } catch (error) {
        if (!quiet) console.error('MemberScript #number257: could not load notifications.', error);
      } finally {
        busy = false;
        hide(child(dropdownRoot, 'loading'));
        hide(child(pageRoot, 'loading'));
      }
    }

    async function init() {
      // An inbound ?id= link opens the detail once, then the parameter is
      // dropped so a reload or a later visit lands on the plain list.
      openId = queryId();
      if (openId) window.history.replaceState({}, '', window.location.pathname);
      if (!openId) hide(detailRoot);
      else if (!overlay) hide(pageRoot);
      show(child(dropdownRoot, 'loading'));
      show(child(pageRoot, 'loading'));
      try {
        var res = await memberstack.getCurrentMember();
        member = (res && res.data) || res;
        if (!member || !member.id) member = null;
      } catch (error) {
        member = null;
      }
      await refresh();
      if (openId && statusOf(openId) !== 'complete') markRead(openId);
      document.addEventListener('keydown', function (event) {
        if (event.key === 'Escape' && detailRoot && !detailRoot.getAttribute('data-ms-hidden')) closeDetail();
      });
      if (poll > 0) setInterval(function () { if (!document.hidden) refresh(true); }, poll * 1000);
      document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(true); });
      window.addEventListener('focus', function () { refresh(true); });
    }

    init();
  });
})();
</script>

Script Info

Versionv0.1
PublishedAug 24, 2026
Last UpdatedAug 24, 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 Data Tables