#217 - Upvote/Downvote Counters For CMS Items

Add upvote and downvote buttons with a live vote counter.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

327 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #217 v0.1 💙 LIKE / VOTE COUNTER(MEMBERSTACK DATA TABLES) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
  // CONFIG: customize these to match your design
  const CONFIG = {
    tableName: "votes",
    pageSize: 100,
    loginAlertMessage: "Please login to vote.",
    itemIdAttribute: "data-item-id",
    itemSelector: "[data-item-id]",
    countSelector: "[data-vote-count]",
    upSelector: "[data-vote-up]",
    downSelector: "[data-vote-down]",
    activeUpClass: "is-upvoted",
    activeDownClass: "is-downvoted",
    upHoverColor: "#03035e",
    downHoverColor: "#7f1d1d",
    upActiveIcon: "#4353ff",
    downActiveIcon: "#e11d48",
    upCountColor: "#4353ff",
    downCountColor: "#e11d48"
  };

  document.documentElement.style.setProperty(
    "--ms217-vote-up-hover-fill",
    CONFIG.upHoverColor
  );
  document.documentElement.style.setProperty(
    "--ms217-vote-down-hover-fill",
    CONFIG.downHoverColor
  );
  document.documentElement.style.setProperty(
    "--ms217-vote-up-active-icon",
    CONFIG.upActiveIcon
  );
  document.documentElement.style.setProperty(
    "--ms217-vote-down-active-icon",
    CONFIG.downActiveIcon
  );
  document.documentElement.style.setProperty(
    "--ms217-vote-up-active-count",
    CONFIG.upCountColor
  );
  document.documentElement.style.setProperty(
    "--ms217-vote-down-active-count",
    CONFIG.downCountColor
  );

  const getMS = async () => window.$memberstackDom || null;

  const getCurrentMemberSafe = async (ms) => {
    try {
      const res = await ms.getCurrentMember();
      return res && (res.data || res);
    } catch (error) {
      console.warn("memberscript217 current member error:", error);
      return null;
    }
  };

  const getItemIdFromCard = (card) => {
    if (!card) return null;
    const attr = CONFIG.itemIdAttribute || "data-item-id";
    const value =
      card.getAttribute(attr) ||
      card.getAttribute("data-ms-record-id") ||
      card.getAttribute("data-item-id");
    return value || null;
  };

  const fetchAllVotesForItems = async (ms, itemIds) => {
    const all = [];
    let skip = 0;
    let page = [];
    do {
      const result = await ms.queryDataRecords({
        table: CONFIG.tableName,
        query: {
          where: { item: { in: itemIds } },
          take: CONFIG.pageSize,
          skip
        }
      });
      page = (result.data && result.data.records) || [];
      all.push(...page);
      skip += page.length;
    } while (page.length === CONFIG.pageSize);
    return all;
  };

  const updateDisplay = (card, netCount, userType) => {
    if (!card) return;

    const countEl = card.querySelector(CONFIG.countSelector);
    const upEl = card.querySelector(CONFIG.upSelector);
    const downEl = card.querySelector(CONFIG.downSelector);

    if (countEl) {
      const value = typeof netCount === "number" ? netCount : 0;
      countEl.textContent = String(value);
      countEl.dataset.voteState =
        userType === "upvote" ? "up" : userType === "downvote" ? "down" : "none";
    }

    if (upEl) {
      upEl.classList.toggle(CONFIG.activeUpClass, userType === "upvote");
    }
    if (downEl) {
      downEl.classList.toggle(CONFIG.activeDownClass, userType === "downvote");
    }
  };

  const syncVotesForCards = async (cards) => {
    const ms = await getMS();
    if (!ms) return;

    const list = Array.from(cards || []);
    if (!list.length) return;

    const itemIds = Array.from(
      new Set(
        list
          .map((card) => getItemIdFromCard(card))
          .filter(Boolean)
      )
    );
    if (!itemIds.length) return;

    try {
      const member = await getCurrentMemberSafe(ms);
      const records = await fetchAllVotesForItems(ms, itemIds);

      const byItem = new Map();
      records.forEach((record) => {
        const rawItem = record && record.data && record.data.item;
        const itemId = rawItem && (rawItem.id || rawItem);
        if (!itemId) return;
        if (!byItem.has(itemId)) {
          byItem.set(itemId, { up: 0, down: 0, userRecord: null });
        }
        const entry = byItem.get(itemId);
        const t = record.data && record.data.type;
        if (t === "upvote") entry.up += 1;
        else if (t === "downvote") entry.down += 1;

        if (
          member &&
          !entry.userRecord &&
          record.data &&
          record.data.member === member.id
        ) {
          entry.userRecord = record;
        }
      });

      list.forEach((card) => {
        const itemId = getItemIdFromCard(card);
        if (!itemId) return;
        const entry = byItem.get(itemId) || { up: 0, down: 0, userRecord: null };
        const net = entry.up - entry.down;
        let userType = null;
        let userRecordId = null;
        if (entry.userRecord) {
          userRecordId = entry.userRecord.id || null;
          userType = entry.userRecord.data && entry.userRecord.data.type;
        }

        if (userRecordId) {
          card.dataset.msVoteRecordId = userRecordId;
          card.dataset.msVoteType = userType || "";
        } else {
          delete card.dataset.msVoteRecordId;
          delete card.dataset.msVoteType;
        }

        updateDisplay(card, net, userType);
      });
    } catch (error) {
      console.warn("memberscript217 sync failed:", error);
    }
  };

  const handleVoteAction = async (card, desiredType) => {
    const ms = await getMS();
    if (!ms || !card || !desiredType) return;

    const member = await getCurrentMemberSafe(ms);
    if (!member) {
      alert(CONFIG.loginAlertMessage);
      return;
    }

    const itemId = getItemIdFromCard(card);
    if (!itemId) return;

    const existingRecordId = card.dataset.msVoteRecordId || null;
    const existingType = card.dataset.msVoteType || null;

    const countEl = card.querySelector(CONFIG.countSelector);
    const currentNet = countEl ? Number(countEl.textContent) || 0 : 0;

    card.classList.add("vote_loading");

    const computeNewNet = () => {
      if (!existingRecordId) {
        return desiredType === "upvote" ? currentNet + 1 : currentNet - 1;
      }
      if (existingType === desiredType) {
        return desiredType === "upvote" ? currentNet - 1 : currentNet + 1;
      }
      return desiredType === "upvote" ? currentNet + 2 : currentNet - 2;
    };

    try {
      const newNet = computeNewNet();

      if (existingRecordId && existingType === desiredType) {
        updateDisplay(card, newNet, null);
        delete card.dataset.msVoteRecordId;
        delete card.dataset.msVoteType;
        await ms.deleteDataRecord({ recordId: existingRecordId });
      } else if (existingRecordId && existingType && existingType !== desiredType) {
        updateDisplay(card, newNet, desiredType);
        card.dataset.msVoteType = desiredType;
        const now = new Date().toISOString();
        await ms.updateDataRecord({
          recordId: existingRecordId,
          data: {
            type: desiredType,
            updated_at: now
          }
        });
      } else {
        const now = new Date().toISOString();
        const baseData = {
          item: itemId,
          member: member.id,
                type: desiredType,
          created_at: now,
      created_at: now
        };

    updateDisplay(card, newNet, desiredType);

        let result;
        try {
          result = await ms.createDataRecord({
            table: CONFIG.tableName,
            data: baseData
          });
        } catch (directIdError) {
          result = await ms.createDataRecord({
            table: CONFIG.tableName,
            data: {
              ...baseData,
              item: { id: itemId }
            }
          });
        }

        const newId = result && result.data && result.data.id;
    if (newId) {
          card.dataset.msVoteRecordId = newId;
          card.dataset.msVoteType = desiredType;
        }
      }
        } catch (error) {
      console.error("memberscript217 action failed:", error);
      syncVotesForCards([card]);
    } finally {
      card.classList.remove("vote_loading");
    }
  };

  const voteItems = document.querySelectorAll(CONFIG.itemSelector);
  if (voteItems.length) {
    syncVotesForCards(voteItems);

    voteItems.forEach((card) => {
      const upEl = card.querySelector(CONFIG.upSelector);
      const downEl = card.querySelector(CONFIG.downSelector);

      if (upEl) {
        upEl.addEventListener("click", (event) => {
          event.preventDefault();
          handleVoteAction(card, "upvote");
        });
      }

      if (downEl) {
        downEl.addEventListener("click", (event) => {
          event.preventDefault();
          handleVoteAction(card, "downvote");
        });
      }
    });
  }
});
</script>

<style>
.vote_icon[data-vote-up]:hover {
  fill: var(--ms217-vote-up-hover-fill, #03035e);
  transform: translateY(-2px);
}

.vote_icon[data-vote-down]:hover {
  fill: var(--ms217-vote-down-hover-fill, #7f1d1d);
  transform: translateY(2px);
}

.vote_icon[data-vote-up].is-upvoted {
  fill: var(--ms217-vote-up-active-icon, #4353ff);
}

.vote_icon[data-vote-down].is-downvoted {
  fill: var(--ms217-vote-down-active-icon, #e11d48);
}

.vote_count[data-vote-state="up"] {
  color: var(--ms217-vote-up-active-count, #4353ff);
}

.vote_count[data-vote-state="down"] {
  color: var(--ms217-vote-down-active-count, #e11d48);
}
</style>

Script Info

Versionv0.1
PublishedMar 16, 2026
Last UpdatedMar 16, 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 Webflow CMS