v0.1

UX
#95 - Confetti On Click
Make some fun confetti fly on click!
Give members a bell with unread counts, a full notification list, and a detail view that opens without leaving the page.
Watch the video for step-by-step implementation instructions
<!-- 💙 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>More scripts in Data Tables