#193 - Member Activity Timeline Tracker v0.1

Track and display all member interactions in a timeline.

Demo ansehen


<!-- 💙 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 bei
Anmerkungen zur Version
Attribute
Beschreibung
Attribut
Keine Artikel gefunden.
Leitfäden / Tutorials
Keine Artikel gefunden.
Tutorial
Was ist Memberstack?

Autorisierung 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.

Mehr erfahren

"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.

Jamie Debnam
39 Digital

"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."

Félix Meens
Webflix-Studio

"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."

Eric McQuesten
Gesundheitstechnologie-Nerds
Off World Depot

"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."

Riley Brown
Off World Depot

"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."

Abtei Burtis
Gesundheitstechnologie-Nerds
Slack

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