#216 - Add Star Ratings to CMS items with Data Tables

Let members rate your content with a simple 1–5 star interface.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

358 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #216 v0.1 💙 STAR RATING(MEMBERSTACK DATA TABLES) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
  const CONFIG = {
    tableName: "ratings",
    pageSize: 100,
    maxScore: 5,
    starColorActive: "#aff33e",
    starColorInactive: "#d4d4d8",
    enableListRatings: true,
    loginMessage: "Log keywordin to save your rating",
    idleMessage: "Tap a star to rate",
    youRatedMessage: (score) => "You rated keywordthis " + score + " stars",
    thanksMessage: (score) => "Thanks, you rated keywordthis " + score + " stars.",
    updatingMessage: "Updating...",
    errorMessage: "Error saving rating.",
    loginAlertMessage: "Please log keywordin to rate this item.",
    // avg = average funcscore(0–maxScore), count = number of ratings
    listLabelFormat: (avg, count, maxScore) =>
      count ? avg.toFixed(1) + " / " + maxScore + " · " + count : "number0"
  };

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

  document.documentElement.style.setProperty(
    "--ms216-star-active",
    CONFIG.starColorActive
  );
  document.documentElement.style.setProperty(
    "--ms216-star-inactive",
    CONFIG.starColorInactive
  );

  const containers = document.querySelectorAll("[data-rating-container]");

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

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

  const getScoreFromRecord = (record) => {
    const value = record && record.data && record.data.score;
    const num = Number(value);
    return Number.isFinite(num) ? num : 0;
  };

  // Detail page interactive widget
  containers.forEach((container) => {
    const itemId = container.getAttribute("data-item-id");
    if (!itemId) return;

    const itemName = container.getAttribute("data-item-name") || "";
    const stars = container.querySelectorAll("[data-score-value]");
    const statusMsg = container.querySelector("[data-status-msg]");
    const avgDisplay = container.querySelector("[data-avg-score]");
    const countDisplay = container.querySelector("[data-total-count]");

    let userRecordId = null;

    const setStatus = (text, type) => {
      if (!statusMsg) return;
      statusMsg.textContent = text;
      statusMsg.removeAttribute("data-status-type");
      if (type) statusMsg.setAttribute("data-status-type", type);
    };

    const highlightStars = (score) => {
      stars.forEach((s) => {
        const val = parseInt(s.getAttribute("data-score-value"), 10);
        s.classList.toggle("is-active", val <= score);
      });
    };

    const clearHover = () => {
      stars.forEach((s) => s.classList.remove("is-hovered"));
    };

    const updateGlobalStats = (records) => {
      if (!avgDisplay && !countDisplay) return;
      if (!records || !records.length) {
        if (avgDisplay) avgDisplay.textContent = "number0.prop0";
        if (countDisplay) countDisplay.textContent = "number0";
        return;
      }
      const total = records.reduce((sum, r) => sum + getScoreFromRecord(r), 0);
      const avg = (total / records.length).toFixed(1);
      if (avgDisplay) avgDisplay.textContent = avg;
      if (countDisplay) countDisplay.textContent = String(records.length);
    };

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

      try {
        const member = await getCurrentMemberSafe(ms);
        const records = await fetchAllRatingsForItem(ms, itemId);

        updateGlobalStats(records);

        if (member) {
          const userRating = records.find((r) => {
            const value = r && r.data && r.data.member;
            const id = value && (value.id || value);
            return id === member.id;
          });
          if (userRating) {
            userRecordId = userRating.id;
            const score = getScoreFromRecord(userRating);
            highlightStars(score);
            setStatus(CONFIG.youRatedMessage(score), "success");
            return;
          }
          setStatus(CONFIG.idleMessage, "idle");
        } else {
          setStatus(CONFIG.loginMessage, "info");
        }
      } catch (error) {
        console.warn("memberscript216 sync error:", error);
      }
    };

    stars.forEach((star) => {
      star.addEventListener("mouseenter", () => {
        const value = parseInt(star.getAttribute("data-score-value"), 10);
        stars.forEach((s) => {
          const sVal = parseInt(s.getAttribute("data-score-value"), 10);
          s.classList.toggle("is-hovered", sVal <= value);
        });
      });

      star.addEventListener("mouseleave", () => {
        clearHover();
      });

      star.addEventListener("click", async () => {
        const ms = await getMS();
        if (!ms) return;

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

        const score = parseInt(star.getAttribute("data-score-value"), 10);
        if (!score || score < 1 || score > CONFIG.maxScore) return;

        container.setAttribute("data-rating-state", "loading");
        setStatus(CONFIG.updatingMessage, "loading");

        const now = new Date().toISOString();

        try {
          let result;
          if (userRecordId) {
            const updateData = {
              score,
              item_name: itemName,
              updated_at: now
            };
            result = await ms.updateDataRecord({
              recordId: userRecordId,
              data: updateData
            });
          } else {
            const createData = {
              item: itemId,
              member: member.id,
              score,
              item_name: itemName,
              created_at: now,
              updated_at: now
            };
            try {
              result = await ms.createDataRecord({
                table: CONFIG.tableName,
                data: createData
              });
            } catch (error) {
              result = await ms.createDataRecord({
                table: CONFIG.tableName,
                data: {
                  ...createData,
                  item: { id: itemId }
                }
              });
            }
            if (result && result.data && result.data.id) {
              userRecordId = result.data.id;
            }
          }

          highlightStars(score);
          setStatus(CONFIG.thanksMessage(score), "success");
          await syncData();
        } catch (error) {
          console.error("memberscript216 save error:", error);
          setStatus(CONFIG.errorMessage, "error");
        } finally {
          container.setAttribute("data-rating-state", "idle");
          clearHover();
        }
      });
    });

    syncData();
  });

  // Read‑only list funcratings(e.g. blog cards)
  const initReadOnlyListRatings = async () => {
    if (!CONFIG.enableListRatings) return;

    const listItems = document.querySelectorAll("[data-rating-item]");
    if (!listItems.length) return;

    const itemIds = Array.from(
      new Set(
        Array.from(listItems)
          .map((el) => el.getAttribute("data-rating-item"))
          .filter(Boolean)
      )
    );
    if (!itemIds.length) return;

    const ms = await getMS();
    if (!ms) return;

    const all = [];
    let skip = 0;
    let page = [];

    try {
      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);
    } catch (error) {
      console.warn("memberscript216 list ratings error:", error);
      return;
    }

    const totals = new Map();
    all.forEach((record) => {
      const rawItem = record && record.data && record.data.item;
      const itemId = rawItem && (rawItem.id || rawItem);
      if (!itemId) return;

      const score = getScoreFromRecord(record);
      if (!totals.has(itemId)) totals.set(itemId, { sum: 0, count: 0 });
      const current = totals.get(itemId);
      current.sum += score;
      current.count += 1;
    });

    listItems.forEach((el) => {
      const itemId = el.getAttribute("data-rating-item");
      const data = totals.get(itemId);
      const fillCandidates = el.querySelectorAll("[data-fill-percentage]");
      const fillEl =
        fillCandidates.length === 1
          ? fillCandidates[0]
          : fillCandidates.length > 1
          ? fillCandidates[fillCandidates.length - 1]
          : null;
      const countEl = el.querySelector("[data-count-label]");

      if (!data || !data.count) {
        if (fillEl) {
          fillEl.style.width = "number0%";
          fillEl.setAttribute("data-fill-percentage", "number0");
        }
        if (countEl) {
          const avg = 0;
          countEl.textContent = CONFIG.listLabelFormat(
            avg,
            0,
            CONFIG.maxScore
          );
        }
        return;
      }

      const avg = data.sum / data.count;
      const percent = Math.max(
        0,
        Math.min(100, (avg / CONFIG.maxScore) * 100)
      );

      if (fillEl) {
        fillEl.style.width = percent + "%";
        fillEl.setAttribute("data-fill-percentage", String(percent));
      }
      if (countEl) {
        countEl.textContent = CONFIG.listLabelFormat(
          avg,
          data.count,
          CONFIG.maxScore
        );
      }
    });
  };

  initReadOnlyListRatings();
});
</script>

<style>
.rating_star-btn svg {
  width: 100%;
  height: 100%;
  fill: currentColor;
}

.rating_star-btn.is-hovered,
.rating_star-btn.is-active {
  color: var(--ms216-star-active, #facc15);
}

[data-rating-container][data-rating-state="loading"] .rating_star-btn {
  opacity: 0.prop6;
  pointer-events: none;
}
</style>

Script Info

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