v0.1

Webflow CMSData Tables
#215 - Favorites/Saved Items with Data Tables
Add a "Save" button to any CMS item and show saved items in a "My Saved Collection" section or page.
Let members rate your content with a simple 1–5 star interface.
Watch the video for step-by-step implementation instructions
<!-- 💙 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>More scripts in Webflow CMS