Comments and rating for shop products with firebase (Part 1)

Implemented commenting and rating ability for my users and potential customers. This does in no way rely on squarespace apart from embedding the elements in the existing store product page. This code will be expanded later to incorporate rating display in category view, for now everything happens on the product page. I am using firebase to store the comments and the ratings, per product. Currently the functionality includes creating and posting comments, along with rating of one to five stars, displaying the comments and average rating of the product. Later I will also add the functionality to delete and edit your own comments. Do not be discouraged by the bulk of code, it is pretty straight forward. As always I get the product ID from a JSON of the product page, however I later found out that it is redundant as it is also listed in a data attribute. Comments now also include pagination, to load more comments when exceeding the maximum five.

HTML:

<div id="commentsMainContainer">
<div id="commentsSecondContainer">
<div id="comments-section">
<p id="comments-section-title">COMMENTS:<p>
<div id="comment-list">
</div>
<div id="load-more-comments">Read more...</div>
</div>
<div id="comment-form">
<p id="comments-section-leaveText">Leave a comment:<p>
<div id="rating-stars" class="rating-stars">
<div class="star" data-rating="1">★</div>
<div class="star" data-rating="2">★</div>
<div class="star" data-rating="3">★</div>
<div class="star" data-rating="4">★</div>
<div class="star" data-rating="5">★</div>
</div>
<div id="comment-textarea" class="custom-textarea" contenteditable="true" placeholder="Write your comment..."></div>
<div id="commentsSubmitButtonContainer">
<button id="submit-comment" class="formButton sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element">Send</button>
</div>
</div>
</div>
</div>
JAVASCRIPT:

<script type="module">
// Initialize Firebase
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js';
import { getAuth } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js';
import { getFirestore, doc, setDoc, getDoc, collection, query, getDocs, limit, where, orderBy, startAfter, deleteDoc } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore.js';
const firebaseConfig = {
apiKey: "----",
authDomain: "-----",
projectId: "------",
storageBucket: "------",
messagingSenderId: "------",
appId: "-----",
measurementId: "-----"
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
// Main
document.addEventListener('DOMContentLoaded', async function () {
const subString = 'shop/p/';
const getURL = window.location.href;
const commentsMainContainer = document.getElementById('commentsMainContainer');
if (getURL.includes(subString)) {
const productSummary = document.getElementsByClassName('ProductItem')[0];
const summaryElement = productSummary.querySelector('.ProductItem-summary');
productSummary.insertBefore(commentsMainContainer, summaryElement.nextSibling);
const commentList = document.getElementById('comment-list');
const loadMoreComments = document.getElementById('load-more-comments');
const submitCommentButton = document.getElementById('submit-comment');
const commentText = document.getElementById('comment-textarea');
const stars = document.querySelectorAll('#rating-stars .star');
const productTitleElement = document.querySelector('.ProductItem-details-title');
let selectedRating = 0;
let lastVisibleComment = null;
let commentsLoaded = 0;
const commentsPerPage = 5;
//initialise popover
let hideTimeout;
var popover = document.getElementById('custom-popover');
var popoverMessage = document.getElementById('popover-message');
function showPopover(message) {
popoverMessage.textContent = message;
popover.classList.add('show');
popover.style.pointerEvents = 'auto';
clearTimeout(hideTimeout);
hideTimeout = setTimeout(function() {
popover.classList.remove('show');
popover.style.pointerEvents = 'none';
}, 3000);
}
// Function to highlight stars based on rating
function highlightStars(rating) {
stars.forEach(star => {
const starRating = star.getAttribute('data-rating');
if (starRating <= rating) {
star.classList.add('hovered');
} else {
star.classList.remove('hovered');
}
});
}
//limit number of input characters
function enforceCharacterLimit(e) {
if (commentText.textContent.length > 420) {
commentText.textContent = commentText.textContent.slice(0, 420);
}
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(commentText);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
commentText.addEventListener('input', enforceCharacterLimit);
commentText.addEventListener('paste', function (e) {
e.preventDefault();
const pasteText = e.clipboardData.getData('text');
const currentText = commentText.textContent;
const newText = currentText + pasteText;
if (newText.length > 420) {
commentText.textContent = newText.slice(0, 420);
} else {
commentText.textContent = newText;
}
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(commentText);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
});
// Handle star hover
stars.forEach(star => {
star.addEventListener('mouseover', function () {
const rating = this.getAttribute('data-rating');
highlightStars(rating);
});
star.addEventListener('mouseout', function () {
highlightStars(selectedRating);
});
star.addEventListener('click', function () {
selectedRating = this.getAttribute('data-rating');
highlightStars(selectedRating);
});
});
// Function to extract product ID
async function extractProductID() {
const jsonURL = `${getURL}?format=json-pretty`;
try {
const response = await fetch(jsonURL);
if (!response.ok) throw new Error('Failed to fetch JSON data');
const data = await response.json();
const itemId = data.item?.id;
if (itemId) {
return itemId;
} else {
console.error('Item ID not found in JSON data');
return null;
}
} catch (error) {
console.error('Error fetching or processing JSON data:', error.message);
return null;
}
}
// Handle comment submission
submitCommentButton.addEventListener('click', async function (event) {
event.preventDefault();
const commentTextContent = commentText.textContent.trim();
const user = auth.currentUser;
if (!user) {
//alert('You must be logged in to leave a comment.');
$('#popoverMessage').off('click');
popoverMessage.addEventListener('click', function() { window.location.href = '/login'; });
popoverMessage.style.color = "#ea4b1a";
showPopover('You must be logged in to leave a comment.');
return;
}
//Check if the user is a "Master"
async function checkIfMaster(userId) {
const userDocRef = doc(db, "users", userId);
const userDoc = await getDoc(userDocRef);
if (userDoc.exists()) {
const userData = userDoc.data();
return userData.Master === true; // Return true if "Master" is true
} else {
console.log("Master document not found.");
return false;
}
}
try {
const isMaster = await checkIfMaster(user.uid);
// Extract product ID
const productID = await extractProductID();
if (!productID) {
console.error('Failed to extract product ID');
return;
}
if (!isMaster) {
// Check if the user has already commented on this product
const commentsCollection = collection(db, 'comments', 'shopComments', productID);
const q = query(commentsCollection, where('userId', '==', user.uid));
const querySnapshot = await getDocs(q);
if (!querySnapshot.empty) {
//alert('You have already commented on this product.');
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ea4b1a";
showPopover('You have already commented on this product.');
return;
}
}
if (!commentTextContent && !selectedRating) {
//alert('You must provide a rating or comment.');
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ea4b1a";
showPopover('Provide a rating, a comment, or both.');
return;
}
const commentData = {
text: commentTextContent,
rating: selectedRating,
userId: user.uid,
timestamp: new Date(),
};
const commentsCollection = collection(db, 'comments', 'shopComments', productID);
// Add a new document to the comments collection
const newCommentRef = doc(commentsCollection);
await setDoc(newCommentRef, commentData);
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ffc700";
showPopover('Comment submitted');
// Reset the comment input and rating
commentText.textContent = '';
selectedRating = 0;
highlightStars(selectedRating);
loadComments();
} catch (error) {
console.error('Error submitting comment:', error);
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ea4b1a";
showPopover('Something went wrong, comment not submitted.');
}
});
// Function to handle the deletion of a comment
async function handleDeleteComment(event) {
event.preventDefault();
const commentId = event.target.getAttribute('data-comment-id');
const commentTimestamp = parseInt(event.target.getAttribute('data-timestamp'), 10);
if (!commentId) {
console.error('Comment ID not found.');
return;
}
const now = new Date().getTime();
const timeDifference = now - commentTimestamp;
const oneDayInMilliseconds = 24 * 60 * 60 * 1000;
if (timeDifference > oneDayInMilliseconds) {
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ea4b1a";
showPopover('Too late now, adventurer.');
return;
}
try {
const productID = await extractProductID();
const commentRef = doc(db, 'comments', 'shopComments', productID, commentId);
await deleteDoc(commentRef); 
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ffc700";
showPopover('Comment removed.');
} catch (error) {
$('#popoverMessage').off('click');
popoverMessage.style.color = "#ea4b1a";
showPopover('Failed to delete comment');
console.log(error);
	}
}
//Load comments from firestore and such
async function loadComments(startAfterDoc = null) {
const productID = await extractProductID();
if (!productID) {
console.error('Failed to extract product ID during comment loading');
return;
}
const commentsCollection = collection(db, 'comments', 'shopComments', productID);
let q = query(commentsCollection, orderBy('timestamp', 'desc'), limit(commentsPerPage));
if (startAfterDoc) {
q = query(commentsCollection, orderBy('timestamp', 'desc'), startAfter(startAfterDoc), limit(commentsPerPage));
}
const querySnapshot = await getDocs(q);
const comments = [];
querySnapshot.forEach((doc) => {
const data = doc.data();
const now = new Date();
const commentTime = data.timestamp.toDate();
const timeDifference = now - commentTime;
const oneDayInMilliseconds = 24 * 60 * 60 * 1000;
if (data.text.trim()) {
comments.push({
id: doc.id,
text: data.text,
rating: Math.min(Math.max(0, data.rating), 5),
timestamp: commentTime,
userId: data.userId,
canDelete: (auth.currentUser && auth.currentUser.uid === data.userId && timeDifference < oneDayInMilliseconds)
});
}
});
if (comments.length > 0) {
if (startAfterDoc === null) {
commentList.innerHTML = '';
}
comments.forEach(comment => {
const commentElement = document.createElement('div');
commentElement.className = 'comment';
const filledStars = '★'.repeat(comment.rating);
const emptyStars = '☆'.repeat(5 - comment.rating);
let deleteLink = '';
if (comment.canDelete) {
deleteLink = `<p class="delete-comment" data-comment-id="${comment.id}" data-timestamp="${comment.timestamp.getTime()}">Remove</p>`;
}
commentElement.innerHTML = `
<div class="rating-stars-commented">${filledStars}${emptyStars}</div>
<p>${comment.text || 'No comment text provided'}</p>
${deleteLink}`;
commentList.appendChild(commentElement);
});
commentsLoaded += comments.length;
if (comments.length < commentsPerPage) {
loadMoreComments.style.display = 'none';
} else {
loadMoreComments.style.display = 'block';
}
if (comments.length > 0) {
lastVisibleComment = querySnapshot.docs[querySnapshot.docs.length - 1];
}
const deleteLinks = document.querySelectorAll('.delete-comment');
deleteLinks.forEach(link => {
link.addEventListener('click', handleDeleteComment);
});
}
}
loadComments();
//display rating of product as stars beneath title
function renderAverageRatingStars(averageRating) {
const roundedRating = Math.min(Math.max(0, Math.round(averageRating)), 5);
const filledStars = '★'.repeat(roundedRating);
const emptyStars = '☆'.repeat(5 - roundedRating);
const ratingStarsElement = document.createElement('div');
ratingStarsElement.className = 'rating-stars-title';
ratingStarsElement.innerHTML = `${filledStars}${emptyStars}`;
productTitleElement.insertAdjacentElement('beforeend', ratingStarsElement);
}
async function displayAverageRating() {
const productID = await extractProductID();
if (!productID) {
console.error('Failed to extract product ID while fetching rating');
return;
}
const commentsCollection = collection(db, 'comments', 'shopComments', productID);
const q = query(commentsCollection);
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
return;
}
let totalRating = 0;
let numberOfRatings = 0;
let averageRating = null;
querySnapshot.forEach((doc) => {
const data = doc.data();
if (data.rating > 0) {
const rating = Number(data.rating);
totalRating += rating;
numberOfRatings++;
}
});
if (numberOfRatings > 0) {
averageRating = totalRating / numberOfRatings;
renderAverageRatingStars(averageRating);
}
}
displayAverageRating();  
} else {
commentsMainContainer.remove();
}
});
</script>
CSS:

#commentsSubmitButtonContainer {
  display: flex;
  justify-content: flex-end;
}
#submit-comment {
  height: 40px;
  line-height: 4px !important;
  max-width: 330px;
}
@media only screen and (max-width:790px) {
#submit-comment {
  max-width: 100%;
  }
}
#commentsMainContainer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
}
#commentsSecondContainer {
  width: 44.4%;
}
@media only screen and (max-width:790px) {
#commentsSecondContainer {
  width: 100%
  }
}
#comment-textarea {
  min-height: 1em !important;
  background: transparent !important;
  border: 0 !important;
  border-radius: 0 !important;
  border-bottom: solid 2px black !important;
  border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%, rgba(0,0,0,1) 80%, rgba(0,0,0,0) 100%) !important;
  border-image-slice: 1 !important;
  margin-bottom: 30px;
  text-align: justify;
  padding-bottom: 0px !important;
  outline: none;
}
#comments-section-leaveText {
  margin-bottom: -15px;
  margin-top: 40px;
}
#comments-section-title {
  margin-bottom: -5px;
  letter-spacing: 0.35rem;
  font-size: 1.8rem;
}
.rating-stars-commented {
  display: flex;
  direction: row;
  font-size: 1.4rem;
  color: #000000;
  margin-top: 20px;
}
.rating-stars {
  display: flex;
  direction: row;
  font-size: 2rem;
  color: rgba(0, 0, 0, 0.3);
  margin: 0;
  margin-top: 25px;
  margin-bottom: -10px;
}
.rating-stars-title {
  display: flex;
  direction: row;
  font-size: 2rem;
  color: black;
  padding: 0;
  margin: 0;
  cursor: default;
  margin-top: -20px;
  margin-bottom: -30px;
}
@media only screen and (max-width:790px) {
.rating-stars-title {
  margin-top: 0px;
  margin-bottom: -15px;
  }
}
.star {
  cursor: pointer;
  margin-right: 0.2rem;
}
.star:hover,
.star.hovered,
.star.selected {
    color: black;
}
#comment-textarea {
  border: 1px solid #ccc;
  padding: 0.5rem;
  margin-top: 0.5rem;
  min-height: 100px;
  overflow-y: auto;
  border-radius: 4px;
  background-color: #f9f9f9;
  cursor: text;
}
.comment p {
  margin-bottom: 3px;
  margin-top: 3px;
  text-align: justify;
}
.comment {
  border-bottom: solid 2px;
  border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%, rgba(0,0,0,1) 80%, rgba(0,0,0,0) 100%) !important;
  border-image-slice: 1 !important;
}

If you want the popover to function, look at my previous post about it.

Previous
Previous

Comments and rating for shop products with firebase (Part 2)

Next
Next

Snappy Lightbox for gallery