#193 - Member Activity Timeline Tracker v0.1
Track and display all member interactions in a timeline.
<!-- 💙 MEMBERSCRIPT #193 v0.1 💙 - MEMBER ACTIVITY TRACKER -->
<script>
(function() {
'use strict';
// CUSTOMIZE: Configuration options
const CONFIG = {
DATA_TABLE_NAME: 'memberactivities', // Change to match your Memberstack Data Table name
DATE_FORMAT: { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, // Customize date format
LOG_DEBOUNCE: 500, // Delay in ms before saving activities (reduce API calls)
MAX_ACTIVITIES: 100 // Maximum activities to fetch and display
};
let memberstack = null, currentMember = null, queue = [], timeout = null, isSubmitting = false;
function log(msg) { console.log('MS#193:', msg); }
function waitForMS() {
return new Promise(r => {
if (window.$memberstackDom) return r();
const t = setTimeout(r, 3000);
document.addEventListener('memberstack.ready', () => { clearTimeout(t); r(); }, { once: true });
});
}
async function getAllActivities() {
const acts = [];
if (CONFIG.DATA_TABLE_NAME && memberstack?.queryDataRecords) {
try {
const result = await memberstack.queryDataRecords({
table: CONFIG.DATA_TABLE_NAME,
query: { orderBy: { activitytimestamp: 'desc' }, take: CONFIG.MAX_ACTIVITIES }
});
if ('records' in (result.data || {})) {
(result.data.records || []).forEach(rec => {
const d = rec?.data;
if (!d) return;
acts.push({
type: d.activitytype || 'activity',
title: d.activitytitle || 'Activity',
timestamp: d.activitytimestamp || (rec.createdAt ? new Date(rec.createdAt).getTime() : Date.now()),
memberName: d.membername || 'Guest',
memberEmail: d.memberemail || '',
metadata: d.activitymetadata ? (typeof d.activitymetadata === 'string' ? JSON.parse(d.activitymetadata) : d.activitymetadata) : {}
});
});
}
} catch (e) { log('Data Table fetch failed: ' + e.message); }
}
return [...acts, ...queue].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
}
// CUSTOMIZE: Change 'en-US' to your locale (e.g., 'en-GB', 'fr-FR', 'de-DE')
function formatDate(ts) {
const d = new Date(ts);
return isNaN(d.getTime()) ? 'Unknown' : d.toLocaleDateString('en-US', CONFIG.DATE_FORMAT);
}
function getMemberEmail(m) { return m?.auth?.email || m?.email || ''; }
// CUSTOMIZE: Modify if your member data structure stores names differently
function getMemberName(m) {
if (!m) return 'Guest';
const fn = m?.customFields?.['first-name'] || m?.customFields?.firstName || m?.data?.customFields?.['first-name'] || m?.data?.customFields?.firstName || m?.firstName || m?.data?.firstName;
const ln = m?.customFields?.['last-name'] || m?.customFields?.lastName || m?.data?.customFields?.['last-name'] || m?.data?.customFields?.lastName || m?.lastName || m?.data?.lastName;
const full = m?.customFields?.name || m?.data?.customFields?.name || m?.name || m?.data?.name;
if (fn && ln) return `${fn} ${ln}`.trim();
return fn || full || 'Guest';
}
function getElementText(el, activityType) {
if (activityType === 'form' || el.tagName === 'FORM') {
// For forms, prioritize data-name and aria-label which usually have the full form name
return el.getAttribute('data-journey-title') || el.getAttribute('data-name') || el.getAttribute('aria-label') || el.getAttribute('name') || el.getAttribute('id') || el.title || 'form';
}
if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') {
return el.getAttribute('data-journey-title') || el.getAttribute('data-name') || el.getAttribute('aria-label') || el.getAttribute('name') || el.getAttribute('id') || el.getAttribute('placeholder') || el.getAttribute('title') || (el.id ? document.querySelector(`label[for="${el.id}"]`)?.textContent?.trim() : null) || el.tagName.toLowerCase();
}
if (activityType === 'video' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
let txt = el.getAttribute('data-journey-title') || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('alt');
if (!txt) {
const container = el.closest('[data-ms-code="journey-track"]') || el;
txt = container.querySelector('h1, h2, h3, h4, h5, h6')?.textContent?.trim();
if (!txt) {
let p = container.parentElement;
while (p && !txt) { txt = p.querySelector('h1, h2, h3, h4, h5, h6')?.textContent?.trim(); p = p.parentElement; }
}
if (!txt) txt = container.querySelector('video[title], audio[title]')?.getAttribute('title') || 'video';
}
return txt;
}
return el.getAttribute('data-journey-title') || el.textContent?.trim() || el.getAttribute('aria-label') || el.title || el.getAttribute('alt') || 'item';
}
async function logActivity(el, type) {
if (!memberstack) return;
if (!currentMember) {
try { const m = await memberstack.getCurrentMember(); currentMember = m?.data || m; } catch (e) { return; }
}
if (!currentMember) return;
const email = getMemberEmail(currentMember);
const name = getMemberName(currentMember);
const eventType = el.getAttribute('data-journey-event');
const activityType = el.getAttribute('data-journey-type') || type || 'click';
const elementText = getElementText(el, activityType);
const truncatedText = elementText.length > 50 ? elementText.substring(0, 50) + '...' : elementText; // CUSTOMIZE: Change 50 to adjust max text length
// CUSTOMIZE: Modify action verbs to change how activities are described
const eventActions = { 'play': 'played', 'pause': 'paused', 'ended': 'finished watching', 'change': 'changed', 'view': 'viewed', 'visible': 'viewed', 'submit': 'submitted', 'click': 'clicked on' };
const typeActions = { 'link': 'clicked on', 'click': 'clicked on', 'form': 'submitted', 'button': 'clicked', 'video': 'watched', 'download': 'downloaded', 'view': 'viewed', 'custom': 'interacted with' };
const action = eventActions[eventType] || typeActions[activityType] || 'interacted with';
let itemType = '';
if (el.tagName === 'A' || activityType === 'link') itemType = 'link';
else if (el.tagName === 'BUTTON' || activityType === 'button') itemType = 'button';
else if (el.tagName === 'FORM' || activityType === 'form') itemType = 'form';
else if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO' || activityType === 'video') itemType = 'video';
else if (activityType === 'download') itemType = 'file';
// Don't add 'page' for view events - just show "viewed 'text'"
// For form field changes, get the parent form name
let formName = '';
if (eventType === 'change' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')) {
const parentForm = el.closest('form');
if (parentForm) {
// Get form name, prioritizing data-name and aria-label which usually have the full name
formName = parentForm.getAttribute('data-name') ||
parentForm.getAttribute('aria-label') ||
parentForm.getAttribute('data-journey-title') ||
parentForm.getAttribute('name') ||
parentForm.getAttribute('id') ||
parentForm.title ||
'form';
// Clean up form name - format nicely but keep "Form" if it's part of the name
const formNameLower = formName.toLowerCase();
// Only remove "form" suffix if it's a technical identifier (like "email-form" or "email_form")
// Don't remove it if it's a proper name like "Email Form"
if (formNameLower.endsWith('-form') || formNameLower.endsWith('_form')) {
formName = formName.substring(0, formName.length - 5).trim();
// Capitalize first letter of each word after removing technical suffix
if (formName && formName.length > 0) {
formName = formName.split(/[\s-]+/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') + ' Form';
}
} else {
// Capitalize first letter of each word for better readability, keeping original structure
if (formName && formName.length > 0) {
formName = formName.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
}
}
}
}
// Don't add itemType if elementText already contains it (e.g., "Email Form" already has "form")
const textLower = truncatedText.toLowerCase();
const typeLower = itemType.toLowerCase();
const alreadyHasType = itemType && (textLower.endsWith(' ' + typeLower) || textLower.endsWith(typeLower) || textLower.includes(' ' + typeLower + ' '));
// Build descriptive title
let descriptiveTitle = '';
if (formName && eventType === 'change') {
// For form field changes: "Name changed 'Field Name' in the Form Name"
descriptiveTitle = `${name} ${action} "${truncatedText}" in the ${formName}`;
} else if (itemType && !alreadyHasType) {
descriptiveTitle = `${name} ${action} "${truncatedText}" ${itemType}`;
} else {
descriptiveTitle = `${name} ${action} "${truncatedText}"`;
}
// Use descriptive title format (data-journey-title is used as element text, not complete override)
queue.push({
type: activityType,
title: descriptiveTitle,
timestamp: Date.now(),
metadata: { url: el.href || window.location.href, pageTitle: document.title },
memberName: name,
memberEmail: email
});
clearTimeout(timeout);
timeout = setTimeout(saveActivities, CONFIG.LOG_DEBOUNCE);
refreshTimeline();
}
async function saveActivities() {
if (queue.length === 0 || !memberstack || !currentMember) return;
if (CONFIG.DATA_TABLE_NAME && memberstack?.createDataRecord) {
const email = getMemberEmail(currentMember);
const name = getMemberName(currentMember);
for (const a of queue) {
try {
await memberstack.createDataRecord({
table: CONFIG.DATA_TABLE_NAME,
data: {
memberid: currentMember.id || currentMember._id || '',
membername: name,
memberemail: email,
activitytype: a.type,
activitytitle: a.title,
activitytimestamp: a.timestamp,
activitymetadata: JSON.stringify(a.metadata || {})
}
});
} catch (e) { log('Data Table save error: ' + e.message); }
}
}
queue = [];
}
async function refreshTimeline() {
const acts = await getAllActivities();
displayTimeline(acts);
}
// CUSTOMIZE: Modify display properties (change 'flex' to 'block', 'grid', etc. based on your CSS)
function displayTimeline(activities) {
const container = document.querySelector('[data-ms-code="journey-timeline-container"]');
const template = document.querySelector('[data-ms-code="journey-item-template"]');
const emptyState = document.querySelector('[data-ms-code="journey-empty-state"]');
if (!container || !template) return;
if (emptyState) emptyState.style.display = 'none';
container.querySelectorAll('[data-ms-code="journey-item"]').forEach(el => el.remove());
template.style.display = 'none';
container.style.display = '';
if (!activities || activities.length === 0) {
if (emptyState) emptyState.style.display = 'block';
return;
}
activities.forEach(activity => {
const item = template.cloneNode(true);
item.removeAttribute('data-ms-template');
item.setAttribute('data-ms-code', 'journey-item');
item.style.display = 'flex'; // CUSTOMIZE: Change display type (flex, block, grid, etc.)
const update = (sel, val) => { const el = item.querySelector(sel); if (el) el.textContent = val; };
update('[data-ms-code="journey-activity-type"]', activity.type.charAt(0).toUpperCase() + activity.type.slice(1));
update('[data-ms-code="journey-activity-title"]', activity.title);
update('[data-ms-code="journey-activity-date"]', formatDate(activity.timestamp));
container.appendChild(item);
});
}
function handleVideoEvent(e, eventName) {
const videoEl = e.target.tagName === 'VIDEO' || e.target.tagName === 'AUDIO' ? e.target : null;
const trackedEl = videoEl ? videoEl.closest('[data-ms-code="journey-track"]') : e.target.closest('[data-ms-code="journey-track"]');
if (trackedEl) {
const actualVideo = (trackedEl.tagName === 'VIDEO' || trackedEl.tagName === 'AUDIO') ? trackedEl : trackedEl.querySelector('video, audio');
if (actualVideo || trackedEl.hasAttribute('data-journey-event')) {
const eventType = trackedEl.getAttribute('data-journey-event');
if (!eventType || eventType === eventName) logActivity(trackedEl, 'video');
}
}
}
document.addEventListener('click', (e) => {
// Don't log click events if form is currently being submitted (prevents duplicate logging)
if (isSubmitting) return;
// Don't log click events on submit buttons - they're handled by the submit event
const isSubmitButton = e.target.type === 'submit' ||
(e.target.tagName === 'BUTTON' && e.target.type === 'submit') ||
(e.target.tagName === 'INPUT' && e.target.type === 'submit');
if (isSubmitButton) {
// Set flag immediately to prevent any other events during submission
isSubmitting = true;
setTimeout(() => { isSubmitting = false; }, 1000);
return;
}
const el = e.target.closest('[data-ms-code="journey-track"]');
if (el) {
// Don't log click on form element if it's a form (submit event handles that)
if (el.tagName === 'FORM') {
return;
}
const eventType = el.getAttribute('data-journey-event');
if (!eventType || eventType === 'click') {
logActivity(el, el.getAttribute('data-journey-type') || (el.tagName === 'A' ? 'link' : 'click'));
}
}
}, true);
document.addEventListener('submit', (e) => {
// Set flag to prevent change and click events from logging during form submission
isSubmitting = true;
const form = e.target;
let trackedEl = form.hasAttribute('data-ms-code') && form.getAttribute('data-ms-code') === 'journey-track' ? form : null;
if (!trackedEl && e.submitter) {
trackedEl = e.submitter.hasAttribute('data-ms-code') && e.submitter.getAttribute('data-ms-code') === 'journey-track' ? e.submitter : e.submitter.closest('[data-ms-code="journey-track"]');
}
if (!trackedEl) {
const btn = form.querySelector('button[type="submit"], input[type="submit"]');
if (btn) trackedEl = btn.hasAttribute('data-ms-code') && btn.getAttribute('data-ms-code') === 'journey-track' ? btn : btn.closest('[data-ms-code="journey-track"]');
}
if (trackedEl) {
const eventType = trackedEl.getAttribute('data-journey-event');
if (!eventType || eventType === 'submit') logActivity(trackedEl, 'form');
}
// Reset flag after a short delay to allow form submission to complete
setTimeout(() => { isSubmitting = false; }, 1000);
}, true);
document.addEventListener('play', (e) => handleVideoEvent(e, 'play'), true);
document.addEventListener('pause', (e) => { const el = e.target.closest('[data-ms-code="journey-track"]'); if (el && el.getAttribute('data-journey-event') === 'pause') logActivity(el, 'video'); }, true);
document.addEventListener('ended', (e) => { const el = e.target.closest('[data-ms-code="journey-track"]'); if (el && el.getAttribute('data-journey-event') === 'ended') logActivity(el, 'video'); }, true);
document.addEventListener('change', (e) => {
// Don't log change events if form is currently being submitted
if (isSubmitting) return;
const el = e.target.closest('[data-ms-code="journey-track"]');
if (el && el.getAttribute('data-journey-event') === 'change') logActivity(el, 'form');
}, true);
if ('IntersectionObserver' in window) {
// CUSTOMIZE: Change threshold (0.0-1.0) - 0.5 = element must be 50% visible to trigger
const viewObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const eventType = el.getAttribute('data-journey-event');
if (eventType === 'view' || eventType === 'visible') {
logActivity(el, 'view');
viewObserver.unobserve(el);
}
}
});
}, { threshold: 0.5 });
function observeViewElements() {
document.querySelectorAll('[data-ms-code="journey-track"][data-journey-event="view"]:not([data-journey-observed]), [data-ms-code="journey-track"][data-journey-event="visible"]:not([data-journey-observed])').forEach(el => {
el.setAttribute('data-journey-observed', 'true');
viewObserver.observe(el);
});
}
observeViewElements();
if ('MutationObserver' in window) {
new MutationObserver(observeViewElements).observe(document.body, { childList: true, subtree: true });
}
}
document.addEventListener('journey-track', (e) => {
const el = e.target;
if (el && el.hasAttribute('data-ms-code') && el.getAttribute('data-ms-code') === 'journey-track') {
logActivity(el, el.getAttribute('data-journey-type') || 'custom');
}
}, true);
(async function() {
await waitForMS();
memberstack = window.$memberstackDom;
if (!memberstack) { log('Memberstack not found'); return; }
try {
const m = await memberstack.getCurrentMember();
currentMember = m?.data || m;
if (!currentMember) { log('No member found'); return; }
const acts = await getAllActivities();
displayTimeline(acts);
} catch (e) { log('Init error: ' + e.message); }
})();
})();
</script>
Customer Showcase
Have you used a Memberscript in your project? We’d love to highlight your work and share it with the community!
Erstellen des Make.com-Szenarios
1. Laden Sie den JSON-Blaupause unten, um angegeben zu bekommen.
2. Navigieren Sie zu Make.com und erstellen Sie ein neues Szenario...

3. Klicken Sie auf das kleine Kästchen mit den 3 Punkten und dann auf Blaupause importieren...

4. Laden Sie Ihre Datei hoch und voila! Sie sind bereit, Ihre eigenen Konten zu verknüpfen.
Brauchen Sie Hilfe mit diesem MemberScript?
Alle Memberstack-Kunden können im 2.0 Slack um Unterstützung bitten. Bitte beachten Sie, dass dies keine offiziellen Funktionen sind und der Support nicht garantiert werden kann.
Treten Sie dem 2.0 Slack beiAutorisierung und Zahlungen für Webflow-Websites
Fügen Sie Ihrer Webflow-Website Logins, Abonnements, Gated Content und vieles mehr hinzu - einfach und vollständig anpassbar.
.webp)
"Wir verwenden Memberstack schon seit langem, und Es hat uns geholfen, Dinge zu erreichen, die wir mit Webflow nie für möglich gehalten hätten. Es hat uns ermöglicht, Plattformen mit großer Tiefe und Funktionalität zu bauen, und das Team dahinter war immer sehr hilfsbereit und offen für Feedback.

"Ich habe eine Mitgliedschaftsseite mit Memberstack und Jetboost für einen Kunden erstellt. Es fühlt sich wie Magie an, mit diesen Tools zu bauen. Als jemand, der in einer Agentur gearbeitet hat, wo einige dieser Anwendungen von Grund auf neu programmiert wurden, verstehe ich jetzt endlich den Hype. Das ist viel schneller und viel billiger."

"Eines der besten Produkte, um eine Mitgliederseite zu starten - ich mag die Benutzerfreundlichkeit von Memberstack. Ich war in der Lage, meine Mitgliederseite innerhalb eines Tages einzurichten und zu betreiben.. Einfacher geht's nicht. Außerdem bietet es die Funktionalität, die ich brauche, um die Benutzererfahrung individueller zu gestalten."

"Mein Geschäft wäre ohne Memberstack nicht das, was es ist. Wenn Sie denken, dass $30/Monat teuer sind, versuchen Sie mal, einen Entwickler zu engagieren, der für diesen Preis individuelle Empfehlungen in Ihre Website integriert. Unglaublich flexible Tools für diejenigen, die bereit sind, einige minimale Anstrengungen zu unternehmen und die gut zusammengestellte Dokumentation zu lesen."


"Die Slack-Community ist eine der aktivsten, die ich kenne, und andere Kunden sind bereit, Fragen zu beantworten und Lösungen anzubieten. Ich habe ausführliche Bewertungen von alternativen Tools durchgeführt und wir kommen immer wieder auf Memberstack zurück - sparen Sie sich die Zeit und probieren Sie es aus."

Brauchen Sie Hilfe mit diesem MemberScript? Treten Sie unserer Slack-Community bei!
Treten Sie der Memberstack-Community Slack bei und fragen Sie los! Erwarten Sie eine prompte Antwort von einem Team-Mitglied, einem Memberstack-Experten oder einem anderen Community-Mitglied.
Unserem Slack beitreten
.png)