Development Log
Adding LIVE IMAGE SEARCH
While having a short break, I coded the functionality to search my art galleries in a live, or instant fashion - like all my other searches on this website. If you navigate to collections, from your account page, or simply to my art section, you can click the search icon after it finished scanning, and start typing. Should your query match the name, or description of any of the images in my art section, they will load into the gallery below. The way this is done is by fetching my art section, getting all the links to my art collections while omitting a predefined set of other links which are there. After this we fetch all the images, or rather their “src=” and “alt=” attributes, which are effectively the link to the image itself and the name of the image, or the description given to it. This is stored to be filtered through upon search. There are a few more safety checks included so that no non-sense images show up and that there will be no duplicates. Currently you can like and unlike the found images, just like with any of my art collections. But beware, it is still not possible to add images to an existing collection, apart from overriding it completely, that functionality will come later. As of the last code update, the initial fetch returns the correct number of images and galleries to be searched.
HTML: <input type="text" id="imageSearchInput" class="image-search-input" placeholder="">
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function() { const subString = 'usergallery'; const subStringTwo = 'artland'; const getURL = window.location.href; const imageSearchInput = document.querySelector('#imageSearchInput'); if (getURL.includes(subString) || getURL.includes(subStringTwo)) { const gallerySection = document.querySelector('.gallery-masonry'); const gallery = gallerySection.querySelector('.gallery-masonry-wrapper'); if (getURL.includes(subString)) { //embed into user gallery const searchPlaceBlock = document.getElementById('block-yui_3_17_2_1_1724836764613_4889'); const searchPlaceText = searchPlaceBlock.querySelector('p'); searchPlaceText.textContent = ''; searchPlaceBlock.appendChild(imageSearchInput); } else { //embed into art section const searchPlaceBlock = document.getElementById('block-yui_3_17_2_1_1724869769291_7417'); const searchPlaceText = searchPlaceBlock.querySelector('p'); searchPlaceText.textContent = ''; searchPlaceBlock.appendChild(imageSearchInput); imageSearchInput.classList.add('artsection'); gallerySection.style.display = 'none'; } //check if has text for cosmetic reasons var hasText = false; function checkInput() { hasText = imageSearchInput.value.trim() !== ''; } document.addEventListener('click', function(event) { checkInput(); if (event.target !== imageSearchInput && hasText == false) { imageSearchInput.classList.remove('texted'); if (getURL.includes(subStringTwo)) { gallerySection.style.display = 'none'; } } else { imageSearchInput.classList.add('texted'); gallerySection.style.display = ''; } }); //fetch the links to galleries from art page imageSearchInput.style.textAlign = 'left'; imageSearchInput.value = 'Fetching...'; const artPageURL = '/artland'; const omitURLs = ['/contact', '/legal', '/history', '/artland', '/shop', '/useraccount', '/cart', '/']; const keywordsToExclude = ['#']; fetch(artPageURL) .then(response => response.text()) .then(html => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const pageLinks = Array.from(doc.querySelectorAll('a')); const imagePromises = pageLinks .filter(link => !omitURLs.includes(link.pathname)) .filter(link => !keywordsToExclude.some(keyword => link.href.includes(keyword))) .map(link => { const pageURL = link.href; return fetch(pageURL).then(res => res.text()); }); imageSearchInput.value = imagePromises.length + ' Galleries'; //get the images metadata const forbiddenImageNames = ['Polivantage', 'icon']; Promise.all(imagePromises).then(pagesHTML => { const imagesData = []; const totalImages = pagesHTML.reduce((count, pageHTML) => { const pageDoc = parser.parseFromString(pageHTML, 'text/html'); const images = pageDoc.querySelectorAll('img'); return count + images.length; }, 0); let loadedImages = 0; pagesHTML.forEach(pageHTML => { const pageDoc = parser.parseFromString(pageHTML, 'text/html'); const imageGalleries = pageDoc.querySelectorAll('.gallery-masonry-wrapper'); let images = []; imageGalleries.forEach(gallery => { const galleryImages = gallery.querySelectorAll('img'); images = images.concat(Array.from(galleryImages)); }); setTimeout(() => { images.forEach(img => { if (!imagesData.some(image => image.src === img.src) && !forbiddenImageNames.some(name => img.alt.includes(name))) { imagesData.push({ src: img.src, alt: img.alt }); } }); //show load progress loadedImages += images.length; const progress = Math.floor((loadedImages / totalImages) * 100); imageSearchInput.value = progress + '%'; }); }, 0); //wrap img function to display % due to load timing setTimeout(() => { imageSearchInput.value = loadedImages + ' sigils'; imageSearchInput.disabled = false; }, 500); //delay before final nn images displayed setTimeout(() => { imageSearchInput.style.textAlign = ''; imageSearchInput.value = ''; }, 2000); //delay before input field is cleared of preloader info //search function let debounceTimeout; const forbiddenWords = ['previ', '.', 'scale', 'png', 'jpg', '_']; imageSearchInput.addEventListener('input', function() { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(function() { const searchTerms = imageSearchInput.value.toLowerCase().split(/\s+/).filter(Boolean); if (searchTerms.length === 0 || searchTerms.some(term => forbiddenWords.some(word => term.includes(word))) || searchTerms.some(term => term.length < 3)) { //3 char minimum gallery.innerHTML = ''; return; } gallery.innerHTML = ''; const filteredImages = imagesData .filter(image => searchTerms.every(term => image.alt.toLowerCase().includes(term))) .map(image => { const matchCount = searchTerms.reduce((count, term) => { return count + (image.alt.toLowerCase().includes(term) ? 1 : 0); }, 0); return { ...image, matchCount }; }) .sort((a, b) => b.matchCount - a.matchCount); filteredImages.forEach(image => { //create the image structure and embed into gallery const imgElement = document.createElement('img'); imgElement.src = image.src; imgElement.alt = image.alt; imgElement.setAttribute('data-src', image.src); imgElement.setAttribute('data-image', image.src); imgElement.setAttribute('srcset', image.src); imgElement.classList.add('loaded'); const figureElement = document.createElement('figure'); figureElement.classList.add('gallery-masonry-item'); const divElement = document.createElement('div'); divElement.classList.add('gallery-masonry-item-wrapper', 'preFade', 'fadeIn'); divElement.setAttribute('data-animation-role', "image"); divElement.style.overflow = 'hidden'; const lightboxLink = document.createElement('a'); lightboxLink.classList.add('gallery-masonry-lightbox-link'); lightboxLink.style.height = "100%"; figureElement.appendChild(divElement); divElement.appendChild(lightboxLink); lightboxLink.appendChild(imgElement); gallery.appendChild(figureElement); }); }, 1000); // debounce search delay }); }); }); } else { imageSearchInput.remove(); } }); </script>
CSS: #imageSearchInput { height: 50px !important; display: block; background: transparent; border: 0 !important; font-size: 1.3rem; letter-spacing: 1.8px; outline: none; padding: 0; margin: 0; margin-bottom: 28px; margin-left: -50px; text-align: left; position: relative; z-index: 999; transition: all .7s ease !important; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/2b60bbec-4d1c-4d73-9d6a-009b9bf1d26a/SearchCrosshairIcon.png); background-size: 50px; background-position: bottom 0px right 0px; background-repeat: no-repeat; padding-left: 50px !important; } @media screen and (max-width:790px) { #imageSearchInput { position: relative; text-align: center; width: 90%; background-position: bottom 0px left 58%; margin-left: -15px; top: -5px; margin-bottom: 0; } } #imageSearchInput:focus { outline: none; transition: all .7s ease; background-position: bottom 0px left 0px; } #imageSearchInput.texted { outline: none; transition: all .7s ease; background-position: bottom 0px left -6px; }
Updated Javascript to search for multiple keywords and filter the results by the number of keywords. Also added a progress loader which should show the percentage of images ready for search as they become available. It is a quick operation and this is not necessary even with over a 1000 images, so it appears briefly and almost instantly hits 100%. The 0ms timeout ensures that the numbers are displayed, even though they are calculated quicker than a non-timeout function would allow to update the screen with.
Adding Chat with firebase
Again I will be using firebase to demonstrate a proof of concept. This time we are building a chat box, or rather I have done so already. The chat can be seen in operation if you log into your account and navigate to the chat page from within your account page. Currently the chat box posts messages along with the user name and a time stamp. It displays messages no older than six hours and up to fifty of them, this as a personal preference. There are a few more subtleties to it which you can grasp from the code. This is pretty much a ready to copy and paste implementation, as long as you have configured your firebase correctly. I am yet to do a post on firebase alone, but you can grasp enough information about it from my other posts and the official documentation to implement the required functionality. You might have to change the directories of user information used below, depending on your setup.
HTML: <div id="chatboxMainContainer" class="chatbox-main-container"> <div id="chatboxContainer" class="chatbox-container"> <div class="chatbox-messages" id="chatboxMessages"> </div> <div class="chatbox-input"> <p class="chatbox-username-text" id="chatboxUserNameText">Stranger:</p> <input type="text" id="chatboxMessageInput" placeholder="your communicae" maxlength="200"/> <button id="chatboxSendButton" class="chatboxButton sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element">Post</button> </div> </div> </div>
JAVASCRIPT: <script type="module"> //firebase init import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, onAuthStateChanged, signOut } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, doc, setDoc, getDoc, where, limit, startAfter, serverTimestamp, collection, addDoc, query, orderBy, onSnapshot } 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', function () { const codenameText = document.getElementById('chatboxUserNameText'); let codename = 'Stranger'; var isMaster = false; const cooldownMS = 20000; //how often can u place a msg in ms const scrollTimeDelay = 1000; //time before scrolling down on msg post let lastMessageTime = 0; const subString = 'chatbox'; const getURL = window.location.href; if (getURL.includes(subString)) { const unsubscribe = auth.onAuthStateChanged(async (user) => { if (user) { const userId = user.uid; const userDocRef = doc(db, 'users', userId); const userDoc = await getDoc(userDocRef); const userData = userDoc.data(); codename = userData.codename || 'Stranger'; isMaster = userData.Master === true; codenameText.textContent = codename + ':'; } else { window.location.href = '/login'; //redirect if not logged in return; } unsubscribe(); }); const chatboxTextBlock = document.getElementById('block-yui_3_17_2_1_1724594598043_17623'); const chatboxText = chatboxTextBlock.querySelector('p'); const chatboxContainer = document.getElementById('chatboxMainContainer'); const messagesRef = collection(db, 'chat'); const messagesContainer = document.getElementById('chatboxMessages'); const messageInput = document.getElementById('chatboxMessageInput'); const sendButton = document.getElementById('chatboxSendButton'); chatboxText.textContent = ''; chatboxTextBlock.appendChild(chatboxContainer); //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); } //post message sendButton.addEventListener('click', async () => { const message = messageInput.value.trim(); const currentTime = Date.now(); if (!message) { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Your silence has been noted, now speak.'); return; } else { if (currentTime - lastMessageTime >= cooldownMS || isMaster == true) { const user = auth.currentUser; try { await addDoc(messagesRef, { user: user.uid, userCodename: codename || 'Stranger', text: message, timestamp: serverTimestamp() }); messageInput.value = ''; lastMessageTime = currentTime; setTimeout(function() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }, scrollTimeDelay); //allow time to propagate the msg } catch (error) { console.error("Error adding document: ", error); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('There has been an error posting your message'); } } else { //alert("You can only post once every nn seconds."); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('You can only post once every ' + cooldownMS/1000 + ' seconds'); } } }); messageInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); sendButton.click(); } }); //display messages const displayMessages = (snapshot) => { messagesContainer.innerHTML = ''; snapshot.forEach((doc) => { const data = doc.data(); const timestamp = data.timestamp ? data.timestamp.toDate().toLocaleString() : 'Some time ago'; const messageElement = document.createElement('div'); messageElement.classList.add('chatboxmsgmessage'); messageElement.innerHTML = ` <div class="chatboxmsgname">${data.userCodename}:</div> <div class="chatboxmsgtimestamp">${timestamp}</div> <div class="chatboxmsgtext"><div class="chatboxmsgBullet"></div>${data.text}</div> `; messagesContainer.appendChild(messageElement); }); }; const now = new Date(); const sixHoursAgo = new Date(now.getTime() - (6 * 60 * 60 * 1000)); //oldest msg const q = query( messagesRef, orderBy('timestamp', 'asc'), where('timestamp', '>=', sixHoursAgo), limit(50) // Limit to the most recent msges ); onSnapshot(q, displayMessages); setTimeout(function() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }, scrollTimeDelay); //exit strategy } else { chatboxContainer.remove(); } }); </script>
CSS: #chatboxMainContainer { max-width: 2200px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; margin: 0; padding: 0; } .chatbox-container { // border: 2px solid #000000; border-radius: 0px; background: #ffc700; width: 95%; height: 800px; display: flex; flex-direction: column; } .chatbox-messages { flex: 1; padding: 0; padding-left: 10px; padding-right: 10px; overflow-y: scroll; border-bottom: 2px solid; border-top: 2px solid; border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 15%, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%); border-image-slice: 1; } .chatbox-input { margin: 0; padding: 0; display: flex; background-color: #ffc700; margin-top: 20px; } .chatbox-username-text { padding: 0; margin: 0; letter-spacing: 2px; align-self: center; } .chatbox-input input { flex: 1; padding-left: 10px; padding-right: 10px; padding-bottom: 0px; border-radius: 0px; margin-right: 10px; background: #ffc700; border-top: 0; outline: none !important; letter-spacing: 1.4px; } .chatbox-input button { height: 50px; line-height: 8px !important; min-width: 250px; text-align: right; letter-spacing: 2px !important; background-color: #ffc700; color: #000000; border-radius: 0px; cursor: pointer; } .chatbox-input button:hover { background-color: #000000; color: #ffc700; } .chatboxmsgmessage { margin-bottom: 25px; } .chatboxmsgname { margin-bottom: -8px; letter-spacing: 2px; padding-top: 5px; } .chatboxmsgtimestamp { font-size: 0.8em; color: #000000; letter-spacing: 1.4px; } .chatboxmsgtext { letter-spacing: 1.4px; display: flex; } .chatboxmsgBullet { width: 40px; height: 40px; margin: 0; padding: 0; margin-right: 20px; margin-top: -5px; background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/31b85fc9-5f5f-469d-8e71-8d50545821f4/BulletPointRightIcon1.png); } //mobile chatbox @media only screen and (max-width:790px) { .chatbox-input { display: block; } .chatbox-input button { width: 100%; margin-top: 20px; } .chatbox-input input { width: 94%; } .chatbox-container { height: 800px; } .chatbox-username-text { padding-left: 10px; } }
I am again using my trusted pop-over to display alerts. Should you be interested in it, see its own blog post.
EDIT: Please note: do not place the unsubscribe function outside of the page check statement. If you do your site will be stuck in a redirect loop as soon as you log out, live and learn.
Random Shop product embellishment
I added a creepy figure that appears on a random product in every shop category. Upon hovering over it, the figure slowly hides out of view behind the product image. It is a small embellishment I thought would be nice to have. I do not enable this feature on mobile. Below you will find code to select a random product, create the div, attach it and randomly set its horizontal position along the top of the product image. Then there is a hover event which in conjunction with the CSS makes the div slowly slide down and then removes it completely after a few seconds.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const subString = 'shop'; const subStringTwo = 'shop/p/'; const getURL = window.location.href; if (getURL.includes(subString) && !getURL.includes(subStringTwo)) { // Mobile mode check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); // Randomly pick a product const shopProducts = document.querySelectorAll('.grid-item'); if (shopProducts.length > 0 && isTouch == false) { const randomProduct = shopProducts[Math.floor(Math.random() * shopProducts.length)]; if (randomProduct) { // Attach the bee const productBeeDiv = document.createElement('div'); productBeeDiv.id = 'productBeeDiv'; productBeeDiv.classList.add('productBeeDiv'); randomProduct.appendChild(productBeeDiv); // Randomize position of bee const minMargin = 5; const maxMargin = 70; const randomMarginLeft = Math.floor(Math.random() * (maxMargin - minMargin + 1)) + minMargin; productBeeDiv.style.marginLeft = randomMarginLeft + '%'; //hide logic productBeeDiv.addEventListener('mouseover', function () { productBeeDiv.classList.add('hideAway'); setTimeout(function () { productBeeDiv.remove(); }, 4000); // ms before bee is removed }); } } } }); </script>
CSS: #productBeeDiv { width: 90px; height: 90px; position:absolute; z-index: -5; margin-top: -89px; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/c98ffa06-eefc-4764-9fd3-2bae6cb9b22c/girlSilIcon1.png'); pointer-events: none; cursor: default; pointer-events: auto; background-repeat: no-repeat; background-position: center; background-size: contain; transition: all 2.3s ease-in-out; } #productBeeDiv.hideAway { transform: translateY(100px); }
The naming conventions with the word “bee” are a relic, initially I wanted a little bee sitting on product pages, but for now I opted for the creepy girl.
Instant wish list display in live shop
Previously I added wish lists using firebase to store them per user. The functionality included displaying them on a separate page. This had drawbacks. For example it is not possible, or very difficult to create your own add-to-cart buttons on pages outside the store, in squarespace. I got rid of that separate page and replaced it with a different approach. Now if you click on my wish list, the items appear as a separate category in the shop. I do this by using url attributes. So basically you are taken to the main shop category with an extra tag added to it, upon which the product grid is compared against the wish list begotten from firebase, and all items which are not in the wish list are hidden. This is similar to how my search functions.
HTML: <div id="wishlistPageButtonContainer" class="wishlistPageButtonContainer"> <div id="wishlistPageButton" class="wishlistPageButton"></div> <p id="wishlistPageButtonText">Desires</p> </div>
JAVASCRIPT: <script type="module"> // init firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, collection, getDocs } 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', function() { const subString = 'shop'; const subStringTwo = 'shop/p/'; const urlParams = new URLSearchParams(window.location.search); const view = urlParams.get('view'); let getURL = window.location.href; const wishlistPageButton = document.querySelector('.wishlistPageButton'); const wishlistPageButtonContainer = document.querySelector('.wishlistPageButtonContainer'); if (getURL.includes(subString) && !getURL.includes(subStringTwo)) { onAuthStateChanged(auth, async (user) => { if (user) { let isTouch = false; const headerActions = document.querySelector('.header-actions'); function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); // Function to fetch the wishlist item IDs async function fetchWishlistItemIds() { try { const wishlistRef = collection(db, 'users', user.uid, 'wishlist'); const querySnapshot = await getDocs(wishlistRef); const itemIds = querySnapshot.docs.map(doc => doc.id); return itemIds; } catch (error) { console.error('Error fetching wishlist items:', error); return []; // Return an empty array in case of an error } } // wishlist sorting function async function sortByWishlist() { if (view === 'wishlist') { const wishlist = await fetchWishlistItemIds(); if (wishlist.length > 0) { const products = document.querySelectorAll('.grid-item'); products.forEach(product => { const productId = product.getAttribute('data-item-id'); if (!wishlist.includes(productId)) { product.style.display = 'none'; } }); } else { document.querySelector('.products-flex-container').innerHTML = '<p>No items desired yet.</p>'; } } } //Place the button var nestedCategories = document.querySelector('.nested-category-tree-wrapper'); var categoryList = nestedCategories.querySelector('ul'); categoryList.insertAdjacentElement('afterend', wishlistPageButtonContainer); isTouch ? wishlistPageButtonContainer.classList.add('mobile') : wishlistPageButtonContainer.classList.remove('mobile'); // Handle pushing the button wishlistPageButtonContainer.addEventListener('click', function(event) { event.preventDefault(); window.location.href = '/shop?view=wishlist'; }); // Call the wishlist sorting function if view=wishlist sortByWishlist(); } else { wishlistPageButtonContainer.remove(); } }); } else { wishlistPageButtonContainer.remove(); } }); </script>
CSS: #wishlistPageButtonText{ font-size: 22px; letter-spacing: 2px; text-align: left; } #wishlistPageButtonContainer { display: flex; align-items: center; margin-top: -15px; height: 40px; // border: solid 2px black; cursor: pointer; pointer-events: auto; transition: all 0.6s ease; transform: scale(1) translate(0%); } #wishlistPageButton { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/81708e25-5ea7-489e-b938-457024b47773/hearticon1.png'); width: 22px !important; height: 22px !important; margin: 0; padding: 0; background-repeat: no-repeat; background-position: center; background-size: contain; margin-right: 10px; margin-top: -4px; } #wishlistPageButtonContainer.mobile { margin-left: 5px; justify-content: center; margin-bottom: 20px; } #wishlistPageButtonContainer.mobile:hover { transform: scale(1.22) translate(0%); } #wishlistPageButtonContainer:hover { transition: all 0.3s ease; transform: scale(1.22) translate(8%); }
If you followed the previous blog post about wish lists, you can get rid of the separate wish list page code.
Comments and rating for shop products with firebase (Part 2)
In the previous post I implemented a rating and commenting system for shop products. This was done on the individual product pages. Now I have written a little script to fetch and display the rating on products in the category view. To get the full functionality working, please also use the code from my previous post as it describes how the rating is created, which is necessary before it can be retrieved and displayed. I found out that the product ID, which is a unique identifier for each product in your shop, is listed in a data attribute. This eliminated the need to fetch anything from JSONs simplifying the operation. Good to know for future reference.
HTML:
JAVASCRIPT: <!--SHOP PRODUCT RATING IN CATEGORY VIEW--> <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 } 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'; const subStringTwo = 'shop/p/'; const getURL = window.location.href; if (getURL.includes(subString) && !getURL.includes(subStringTwo)) { const productItems = document.querySelectorAll('.grid-item'); // Function to embed the rating stars beneath the product title function embedRatingStars(productItem, 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-category'; ratingStarsElement.innerHTML = `${filledStars}${emptyStars}`; const productTitleElement = productItem.querySelector('.grid-title'); productTitleElement.insertAdjacentElement('beforeend', ratingStarsElement); } // Function to fetch the average rating from Firestore async function fetchAverageRating(productId) { try { const commentsCollection = collection(db, 'comments', 'shopComments', productId); const q = query(commentsCollection); const querySnapshot = await getDocs(q); if (querySnapshot.empty) { return null; } 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; } return averageRating; } catch (error) { console.error('Error fetching rating:', error); return null; } } //Get product ID function extractProductId(productItem) { return productItem.getAttribute('data-item-id'); } //add the rating for (const productItem of productItems) { const productId = extractProductId(productItem); if (productId) { const averageRating = await fetchAverageRating(productId); if (averageRating !== null) { embedRatingStars(productItem, averageRating); } } } } }); </script>
CSS:
The CSS remains unchanged from part 1.
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.
Snappy Lightbox for gallery
I broke the original squarespace lightbox when creating functionality for the visitors to be able to manage collections of images from my art section, more on that in a later blog post, perhaps. In the meantime I wanted to have a decent image previewing mode. So I wrote a lightbox. A lightbox is an overlay element which allows to more closely inspect individual images from the gallery. Currently if you click outside of the image, or the buttons, it closes the lightbox, unless you are in zoom mode. I implemented lazy loading and a preloader, in case the images take some time to load. If you click the image you get to pan around a zoomed version of it. The arrow keys now allow viewing the previous and next image in the gallery. Very happy with the result. In the code you will find small sections pertaining to a popover, which is my replacement for the stock added-to-cart pop-up. It is described in an earlier post and I am using it frequently instead of alerts. There is also a bit of handy code to determine with certainty whether you are in the mobile version of the site, this I also re-use abundantly. I coded the lightbox for the masonry gallery, which should have the lightbox option enabled, as I use the overlays provided by it, subjugating their link to fit my purposes. If you want to customize the placeholder loader, look in the CSS. I also have two buttons in the top right. These allow viewing the unique identifier of the image and buying the image. To view the process of buying the image, please see my post about auto filling forms. To see the lightbox in action, go to my art section and choose any gallery. Upon clicking on an image it will snappily pop up and light up your day.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const gallerySection = document.querySelector('.gallery-masonry'); if (gallerySection) { //exlude some gallery containing pages from buy and info ability let noInfoBuy = false; const getURL = window.location.href; const badStrings = ['/avanti', 'posters-and-layouts', 'cards-and-logos', '/jumper']; if (badStrings.some(badString => getURL.includes(badString))) { noInfoBuy = true; } let lightbox; let currentIndex = 0; let galleryItems = []; let isZoomed = false; let isInfo = false; //mobile mode check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (!isTouch || isTouch) { //redundant check //initialize 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); } //copy to clipboard function copyToClipboard(text) { if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ffc700"; showPopover('Tag copied to clipboard'); }).catch(err => { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Tag failed to copy'); }); } else { console.error('Clipboard API not supported'); } } //lightbox function hideInfo() { const infoButton = lightbox.querySelector('.custom-info-button'); const copyButton = lightbox.querySelector('.custom-copy-button'); const sideButtonContainer = lightbox.querySelector('.custom-lightbox-button-container'); infoButton.textContent = 'VIEW TAG'; sideButtonContainer.classList.remove('shown'); infoButton.classList.remove('shown'); if (copyButton) { copyButton.remove(); } isInfo = false; } function showInfo() { const infoButton = lightbox.querySelector('.custom-info-button'); const sideButtonContainer = lightbox.querySelector('.custom-lightbox-button-container'); infoButton.textContent = galleryItems[currentIndex].alt || 'No description'; infoButton.classList.add('shown'); sideButtonContainer.classList.add('shown'); const copyButton = document.createElement('div'); copyButton.classList.add('custom-copy-button'); copyButton.textContent = 'COPY'; infoButton.appendChild(copyButton); copyButton.addEventListener('click', function(event) { const textToCopy = infoButton.textContent; sessionStorage.setItem('uniqueTag', textToCopy); copyToClipboard(textToCopy); }); isInfo = true; } function toggleInfo() { const sideButtonContainer = lightbox.querySelector('.custom-lightbox-button-container'); if (isZoomed) { hideInfo(); sideButtonContainer.style.display = 'none'; } else { if (noInfoBuy == false) { sideButtonContainer.style.display = ''; } } } function moveZoomedImage(event) { const lightboxImg = document.querySelector('.custom-lightbox-content'); const rect = lightboxImg.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; const moveX = ((offsetX / rect.width) * 50) - 25; const moveY = ((offsetY / rect.height) * 50) - 25; const maxMoveX = Math.max(Math.min(moveX, 25), -25); const maxMoveY = Math.max(Math.min(moveY, 25), -25); lightboxImg.style.transform = `scale(2) translate(${-maxMoveX}%, ${-maxMoveY}%)`; } function toggleZoom(event) { const lightboxImg = document.querySelector('.custom-lightbox-content'); if (isZoomed) { lightboxImg.classList.remove('zoomed'); lightboxImg.style.transform = ''; lightboxImg.style.cursor = 'zoom-in'; lightbox.removeEventListener('mousemove', moveZoomedImage); } else { lightboxImg.classList.add('zoomed'); lightboxImg.style.cursor = 'zoom-out'; lightbox.addEventListener('mousemove', moveZoomedImage); } isZoomed = !isZoomed; toggleInfo(); } function setupLightbox() { if (!lightbox) { lightbox = document.createElement('div'); lightbox.className = 'custom-lightbox'; lightbox.id = 'custom-lightbox'; const placeholder = document.createElement('div'); placeholder.className = 'custom-lightbox-placeholder'; const placeholderContainer = document.createElement('div'); placeholderContainer.className = 'custom-lightbox-placeholder-container lds-heart'; placeholderContainer.appendChild(placeholder); lightbox.appendChild(placeholderContainer); const lightboxImg = document.createElement('img'); lightboxImg.className = 'custom-lightbox-content'; lightboxImg.id = 'custom-lightbox-img'; lightboxImg.style.display = 'none'; lightbox.appendChild(lightboxImg); const navigation = document.createElement('div'); navigation.className = 'custom-navigation'; const prevBtn = document.createElement('div'); prevBtn.className = 'custom-prev'; prevBtn.innerHTML = '❮'; navigation.appendChild(prevBtn); const nextBtn = document.createElement('div'); nextBtn.className = 'custom-next'; nextBtn.innerHTML = '❯'; navigation.appendChild(nextBtn); lightbox.appendChild(navigation); const sideButtonContainer = document.createElement('div'); sideButtonContainer.className = 'custom-lightbox-button-container'; const infoButton = document.createElement('div'); infoButton.className = 'custom-info-button'; infoButton.textContent = 'VIEW TAG'; const buyButton = document.createElement('div'); buyButton.className = 'custom-lightbox-buy-button'; buyButton.textContent = 'ACQUIRE'; sideButtonContainer.appendChild(infoButton); sideButtonContainer.appendChild(buyButton); lightbox.appendChild(sideButtonContainer); document.body.appendChild(lightbox); if (noInfoBuy == true) { sideButtonContainer.style.display = 'none'; } lightbox.addEventListener('click', function (event) { if (event.target === lightbox || !lightbox.contains(event.target)) { if (isZoomed) { lightboxImg.classList.remove('zoomed'); lightboxImg.style.transform = ''; lightboxImg.style.cursor = 'zoom-in'; lightbox.removeEventListener('mousemove', moveZoomedImage); isZoomed = false; toggleInfo(); } else { if (sideButtonContainer.classList.contains('shown')) { infoButton.textContent = 'VIEW TAG'; infoButton.classList.remove('shown'); sideButtonContainer.classList.remove('shown'); } else { closeLightbox(); } } } else if (event.target.classList.contains('custom-prev') || event.target.classList.contains('custom-next')) { if (event.target.classList.contains('custom-prev')) { currentIndex = (currentIndex > 0) ? currentIndex - 1 : galleryItems.length - 1; loadImage(currentIndex); } else if (event.target.classList.contains('custom-next')) { currentIndex = (currentIndex < galleryItems.length - 1) ? currentIndex + 1 : 0; loadImage(currentIndex); } } else if (event.target === lightboxImg) { if (!isTouch) { toggleZoom(event); } } else if (event.target.classList.contains('custom-info-button')) { isInfo ? hideInfo() : showInfo(); } else if (event.target.classList.contains('custom-lightbox-buy-button')) { const textToCopy = galleryItems[currentIndex].alt; const imageURL = galleryItems[currentIndex].src; sessionStorage.setItem('uniqueTag', textToCopy); sessionStorage.setItem('imageURL', imageURL); copyToClipboard(textToCopy); let productURL = "/shop/p/original-poster"; let queryParam = "view"; let queryValue = "acquire"; let fullURL = `${productURL}?${queryParam}=${queryValue}`; window.location.href = fullURL; } }); } } function preloadImages(imageUrls) { imageUrls.forEach(url => { const img = new Image(); img.src = url; }); } function arrowKeydown(event) { const prevBtn = lightbox.querySelector('.custom-prev'); const nextBtn = lightbox.querySelector('.custom-next'); if (event.key === 'ArrowLeft') { prevBtn.click(); } if (event.key === 'ArrowRight') { nextBtn.click(); } } function loadImage(index) { const lightboxImg = lightbox.querySelector('#custom-lightbox-img'); const item = galleryItems[index]; lightboxImg.classList.remove('zoomed'); lightboxImg.style.transform = ''; lightboxImg.style.cursor = 'zoom-in'; isZoomed = false; toggleInfo(); lightbox.removeEventListener('mousemove', moveZoomedImage); const placeholderContainer = lightbox.querySelector('.custom-lightbox-placeholder-container'); placeholderContainer.style.display = 'flex'; lightboxImg.style.display = 'none'; lightbox.style.display = 'flex'; document.body.style.overflow = 'hidden'; lightboxImg.src = item.src; lightboxImg.alt = item.alt; lightboxImg.onload = function () { placeholderContainer.style.display = 'none'; lightboxImg.style.display = 'block'; hideInfo(); lightboxImg.style.zIndex = '9999'; }; } function openLightbox(index) { if (galleryItems.length === 0) { galleryItems = refreshGalleryItems(); preloadImages(galleryItems); } document.addEventListener('keydown', arrowKeydown); currentIndex = index; setupLightbox(); loadImage(currentIndex); } function closeLightbox() { if (lightbox) { const lightboxImg = lightbox.querySelector('#custom-lightbox-img'); lightboxImg.classList.remove('zoomed'); lightboxImg.style.transform = ''; lightboxImg.style.cursor = 'zoom-in'; isZoomed = false; hideInfo(); lightbox.removeEventListener('mousemove', moveZoomedImage); document.removeEventListener('keydown', arrowKeydown); lightbox.style.display = 'none'; document.body.style.overflow = ''; } } function refreshGalleryItems() { const gallery = gallerySection.querySelector('.gallery-masonry-wrapper'); const galleryLinkElements = gallery.querySelectorAll('.gallery-masonry-lightbox-link'); return Array.from(galleryLinkElements).map(link => { const img = link.querySelector('img'); const imgAlt = img.getAttribute('alt'); const imgSrc = img.getAttribute('src'); return { src: imgSrc, alt: imgAlt }; }); } galleryItems = refreshGalleryItems(); setupLightbox(); function setupGalleryEventListeners() { const galleryLinkElements = gallerySection.querySelectorAll('.gallery-masonry-lightbox-link'); galleryLinkElements.forEach((item, index) => { item.addEventListener('click', function (event) { event.preventDefault(); openLightbox(index); }); }); } setupGalleryEventListeners(); function updateLinks() { const gallery = gallerySection.querySelector('.gallery-masonry-wrapper'); const galleryLinkElements = gallery.querySelectorAll('.gallery-masonry-lightbox-link'); galleryLinkElements.forEach((link) => { link.setAttribute('href', ''); }); } updateLinks(); const observer = new MutationObserver(function () { updateLinks(); galleryItems = refreshGalleryItems(); setupGalleryEventListeners(); }); observer.observe(gallerySection, { childList: true, subtree: true }); } } }); </script>
CSS: //purchase and tag .custom-lightbox-button-container { justify-content: center; position: absolute; color: #ffc700; top: 2.5%; right: 0; margin-right: -40px; transform: translateX(-50%); padding: 0; background-color: #000000; border: 0; cursor: pointer; z-index: 10000; display: block; text-align: center; letter-spacing: 2px; height: auto; max-width: 400px; } .custom-lightbox-button-container.shown { margin-right: -170px; } .custom-lightbox-buy-button { color: #ffc700; padding: 10px 20px; margin-top: 20px; background-color: #000000; border: solid 2px #ffc700; cursor: pointer; z-index: 10000; display: block; text-align: center; letter-spacing: 2px; transition: all 0.3s ease; height: auto; right: 0; } .custom-copy-button { color: #ffc700; padding: 10px 20px; margin-top: 20px; margin-bottom: 10px; background-color: #000000; border: solid 2px #ffc700; cursor: pointer; z-index: 10000; display: block; text-align: center; letter-spacing: 2px; transition: all 0.3s ease; height: auto; right: 20px; } .custom-lightbox-buy-button:hover { color: #000000; background-color: #ffc700; border: solid 2px #000000; } .custom-copy-button:hover { color: #000000; background-color: #ffc700; border: solid 2px #000000; } .custom-info-button { justify-content: center; color: #ffc700; padding: 10px 20px; background-color: #000000; border: solid 2px #ffc700; cursor: pointer; z-index: 10000; display: block; text-align: center; letter-spacing: 2px; transition: all 0.3s ease; height: auto; max-width: 400px; } .custom-info-button:hover { color: #000000; background-color: #ffc700; border: solid 2px #000000; } .custom-info-button.shown:hover { color: #ffc700; background-color: #000000; border: solid 2px #ffc700; cursor: default; } //lightbox .custom-lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.95); justify-content: center; align-items: center; z-index: 9999; } .custom-lightbox-content { max-width: 95%; max-height: 95%; cursor: pointer; transition: transform 0.3s ease; transform-origin: center center; } .custom-lightbox-content.zoomed { cursor: zoom-out; transform: scale(2); } .custom-lightbox-placeholder-container { width: 100%; height: 100%; background: transparent; display: flex; align-items: center; justify-content: center; } .lds-heart .custom-lightbox-placeholder { color: #ffc700 } .custom-navigation { position: absolute; top: 50%; width: 95%; display: flex; justify-content: space-between; padding: 0 10px; } .custom-prev, .custom-next { background-color: #000000; border: solid 2px #ffc700; color: #ffc700; font-size: 24px; line-height: 40px; width: 40px; height: 40px; text-align: center; cursor: pointer; transition: all 0.3s ease; z-index: 99999; } .custom-prev:hover, .custom-next:hover { background-color: #ffc700; color: #000000; border-color: #000000; }
User account page with firebase
The account page, dubbed Hearth, is the prime residency of the logged in user. It has buttons to view your wish list (desires) and liked images (collections). There is a button to edit your squarespace account and a button to edit your profile (the dossier). There is a button to enter peers chat, and a button to chat with the warden AI, which is my customer service chat bot with a strong personality. There is finally, also a button to log. This page will be extended as I make polivantage a more anchored experience. For now the bulk of user customization takes place in the dossier.
HTML:
JAVASCRIPT: <script type="module"> // Initialize Firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, signOut, onAuthStateChanged, updatePassword, reauthenticateWithCredential, EmailAuthProvider } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, collection, setDoc, writeBatch, deleteDoc, getDocs, query, serverTimestamp, where, doc, getDoc } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore.js'; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); //main stuff document.addEventListener('DOMContentLoaded', function() { const subString = 'useraccount'; const getURL = window.location.href; const baseURL = "https://www.polivantage.com/"; //show and hide 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); } if (getURL.includes(subString)) { onAuthStateChanged(auth, async (user) => { if (user) { const imagesLikedTextBlock = document.getElementById('block-yui_3_17_2_1_1723629178851_25945'); const imagesLikedText = imagesLikedTextBlock.querySelector('p'); const statusTextBlock = document.getElementById('block-yui_3_17_2_1_1723629178851_17416'); const statusText = statusTextBlock.querySelector('p'); const SignOutButton = document.getElementById('block-ed832b202fab80c68286'); const desiresButton = document.getElementById('block-77b36cfb140b770fa15e'); const desiresTextBlock = document.getElementById('block-2faf595f9b3381a46ed6'); const desiresText = desiresTextBlock.querySelector('p'); const userAccountBadge = document.querySelector('.user-accounts-link'); const userAccountLink = userAccountBadge.querySelector('a'); const userAccountButton = document.getElementById('block-6456a0bb547b865b3236'); //squarespace user accounts link to button userAccountButton.addEventListener('click', function(event) { event.preventDefault(); userAccountLink.click(); }); //Sign out logic SignOutButton.addEventListener('click', async (event) => { event.preventDefault(); try { const user = auth.currentUser; if (user) { const userId = user.uid; await setDoc(doc(db, 'users', userId), { status: 'offline', lastStatusChange: serverTimestamp() }, { merge: true }); await signOut(auth); window.location.href = '/login'; } } catch (error) { statusText.textContent = '- Stay here traitor!'; // Sign out failed status console.error('Sign out error', error); } }); function checkSelectOptions(productItem) { var selects = productItem.querySelectorAll('select'); for (var i = 0; i < selects.length; i++) { if (selects[i].selectedIndex === 0) { return 'Please Select ' + selects[i].options[0].textContent; } } return null; } //display greeting const userDocRef = doc(db, 'users', user.uid); try { const userDocSnap = await getDoc(userDocRef); if (userDocSnap.exists()) { const userData = userDocSnap.data(); const userCodename = userData.codename || 'secret'; // Default statusText.textContent = userCodename === 'secret' ? 'Welcome Stranger.' : `Welcome ${userCodename}.`; // Use actual codename } else { statusText.textContent = `Welcome Stranger.`; // Default greeting } } catch (error) { console.error('Error fetching user data:', error); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Error fetching your data.'); statusText.textContent = `Welcome Stranger.`; // Default greeting on error } //check if mobile view let isTouch = false; const headerActions = document.querySelector('.header-actions'); function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); //sync wishlist from local and display number of desires and link to wishlist desiresButton.addEventListener('click', function(event) { event.preventDefault(); window.location.href = '/shop?view=wishlist'; }); const wishlistRef = collection(db, 'users', user.uid, 'wishlist'); try { const wishlistSnapshot = await getDocs(wishlistRef); let firestoreWishlist = wishlistSnapshot.docs.map(doc => doc.id); let localWishlist = JSON.parse(localStorage.getItem('wishlist') || '[]'); if (localWishlist){ const combinedWishlist = Array.from(new Set([...localWishlist, ...firestoreWishlist])); const batch = writeBatch(db); combinedWishlist.forEach(itemId => { const itemDocRef = doc(wishlistRef, itemId); batch.set(itemDocRef, { itemId: itemId }); }); await batch.commit(); localStorage.removeItem('wishlist'); } const desireCount = wishlistSnapshot.size; const desireWord = desireCount === 1 ? 'desire' : 'desires'; desiresText.textContent = `You have ${desireCount} ${desireWord}.`; } catch (error) { console.error('Error fetching liked images:', error); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Error fetching desires'); } //Display number of liked images const likedImagesRef = collection(db, 'users', user.uid, 'likedImages'); try { const likedImagesSnapshot = await getDocs(likedImagesRef); const itemCount = likedImagesSnapshot.size; const itemWord = itemCount === 1 ? 'sigil' : 'sigils'; imagesLikedText.textContent = `You are considering ${itemCount} ${itemWord}.`; } catch (error) { console.error('Error fetching liked images:', error); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Error fetching liked images'); } //exit strategy } else { window.location.href = '/login'; //if not logged in redirect } }); } }); </script>
CSS:
This page had more code to change password and code name, this functionality has been moved to the dossier page.
SHOPPING WISH LISTS WITH FIREBASE (PART 2 - Redundant)
I have implemented wish list functionality into my store. Wish list functionality allows users to save and manage a list of desired items or products they may want to purchase in the future. By clicking the heart icon in the category view you can add, or remove products in the wish list individually, and by clicking the button on the cart page, all items in cart are added to the wish list. I use Firebase for this, which I discussed in the previous post. Below you will find a lot of code that handles displaying wished for items on a separate page, however, I just made this code redundant by using a live, or instant approach which filters the shop grid instead. I am keeping the code below for archival purposes only, as it may have a few functions which could be of use later.
HTML:
JAVASCRIPT: <!--WISHLIST DIRECTORY BUTTON IN SHOP--> <div id="wishlistPageButton" class="wishlistPageButton"></div> <script> document.addEventListener('DOMContentLoaded', function() { const subString = 'shop'; const subStringTwo = 'shop/p/'; let getURL = window.location.href; const wishlistPageButton = document.querySelector('.wishlistPageButton'); if (getURL.includes(subString) && !getURL.includes(subStringTwo)) { let isTouch = false; const headerActions = document.querySelector('.header-actions'); function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); //place the button var nestedCategories = document.querySelector('.nested-category-tree-wrapper'); var categoryList = nestedCategories.querySelector('ul'); categoryList.insertAdjacentElement('afterend', wishlistPageButton); isTouch ? wishlistPageButton.classList.add('mobile') : wishlistPageButton.classList.remove('mobile'); //handle pushing the button wishlistPageButton.addEventListener('click', function(event) { window.location.href = '/wishlist'; }); } else { wishlistPageButton.remove(); } }); </script> <!--WISHLIST PAGE--> <script type="module"> // init firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, collection, getDocs, doc, deleteDoc } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore.js'; const firebaseConfig = {apiKey: "AIzaSyBZSF7gaLoqI5ni_ntvvhujH1kL5h5OaEs", authDomain: "-----------", projectId: "--------", storageBucket: "--------------", messagingSenderId: "-------", appId: "-----------------", measurementId: "--------"}; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); // The usual page checks document.addEventListener('DOMContentLoaded', () => { const subString = 'wishlist'; const getURL = window.location.href; if (getURL.includes(subString)) { onAuthStateChanged(auth, async (user) => { if (user) { console.log('User authenticated'); // Function to fetch the wishlist item IDs async function fetchWishlistItemIds() { try { const wishlistRef = collection(db, 'users', user.uid, 'wishlist'); const querySnapshot = await getDocs(wishlistRef); const itemIds = querySnapshot.docs.map(doc => doc.id); // Get item IDs return itemIds; } catch (error) { console.error('Error fetching wishlist items:', error); return []; // Return an empty array in case of an error } } // Function to remove an item from the wishlist async function removeItemFromWishlist(itemId, itemElement) { try { const itemRef = doc(db, 'users', user.uid, 'wishlist', itemId); await deleteDoc(itemRef); console.log(`Item ${itemId} removed from wishlist`); // Hide the specific item element instead of reloading itemElement.style.display = 'none'; // Check if all items are removed and display a message if needed const remainingItems = document.querySelectorAll('#product-grid .product-item'); if (remainingItems.length === 0) { const productContent = document.querySelector('.content-wrapper'); if (productContent) { productContent.innerHTML = `<p>None, how despondent!</p>`; productContent.style.display = 'flex'; } } } catch (error) { console.error('Error removing item from wishlist:', error); } } // Function to fetch and display products async function fetchAndDisplayProducts(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); // Access the products from the 'items' key const products = data.items; // Get wishlist item IDs const wishlistItemIds = await fetchWishlistItemIds(); // Create a grid container element const gridContainer = document.createElement('div'); gridContainer.id = 'product-grid'; gridContainer.className = 'product-grid'; // Flag to check if any products were added let hasProducts = false; // Generate HTML for each product products.forEach((product) => { if (wishlistItemIds.includes(product.id)) { // Check if product is in wishlist hasProducts = true; let assetUrl = product.assetUrl; if (!assetUrl.includes('images.squarespace')) { if (product.items && product.items.length > 0) { assetUrl = product.items[0].assetUrl; } } const productElement = document.createElement('div'); productElement.className = 'product-item'; productElement.innerHTML = ` <a href="${product.fullUrl}"> <img src="${assetUrl}" alt="${product.name}" class="wishlistItemImage" style="opacity: 1; width: 400px; height: 400px; border: solid 2px black; transition: all 0.3s ease !important;"> <h4 class="wishlistItemTitle" style="cursor: pointer">${product.title}</h4> </a> <button class="remove-from-wishlist sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element sqs-block-button-container" style="text-align: right" data-product-id="${product.id}">Discard</button> `; // Append the product element to the grid container gridContainer.appendChild(productElement); } }); const productSection = document.querySelector('[data-section-id="66bbf4f37d8e3666c9d71b7b"]'); const productContent = productSection.querySelector('.content-wrapper'); if (productContent) { productContent.innerHTML = ''; // Clear any existing content if (hasProducts) { productContent.appendChild(gridContainer); } else { productContent.innerHTML = `<p>None, how despondent!</p>`; productContent.style.display = 'flex'; } // Add event listeners to remove buttons document.querySelectorAll('.remove-from-wishlist').forEach(button => { button.addEventListener('click', (event) => { event.preventDefault(); // Prevent default action if inside a link const itemId = button.getAttribute('data-product-id'); const itemElement = button.closest('.product-item'); // Get the product item element removeItemFromWishlist(itemId, itemElement); }); }); } } catch (error) { console.error('Error fetching the products:', error); const productSection = document.querySelector('[data-section-id="66bbf4f37d8e3666c9d71b7b"]'); const productContent = productSection.querySelector('.content-wrapper'); if (productContent) { productContent.innerHTML = `<p>None, how despondent!</p>`; productContent.style.display = 'flex'; } } } const productsUrl = 'https://www.polivantage.com/shop?format=json-pretty'; fetchAndDisplayProducts(productsUrl); } else { console.error('User not authenticated'); window.location.href = '/login'; } }); } }); </script>
CSS: section[data-section-id="66bbf4f37d8e3666c9d71b7b"] .content-wrapper .content { width: 0 !important; } .product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 25px; } @media only screen and (max-width:790px) { .product-grid { grid-template-columns: repeat(1, 1fr); } } .product-item { margin-bottom: 40px; transition: all 0.3s ease; } .product-item:hover .wishlistItemImage { transform: scale(1.05); } .wishlistItemTitle { margin: 0; margin-bottom: 20px; margin-top: 10px; text-align: center; word-wrap: break-word; overflow-wrap: break-word; max-width: 100%; } .wishlistItemImage { margin: 0; transition: all 0.3s ease; } .remove-from-wishlist { width: 100%; text-align: right; } //wishlist category button #wishlistPageButton { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/d88a683e-1625-4ab8-87f8-69467233fa6b/heartGrabbedicon.png'); width: 60px !important; height: 60px !important; margin-top: -15px; pointer-events: auto; cursor: pointer; background-repeat: no-repeat; background-position: center; background-size: contain; transition: all 0.6s ease; scale: 1; } #wishlistPageButton.mobile { margin-left: 160px; margin-bottom: 20px; } #wishlistPageButton:hover { transition: all 0.3s ease; scale: 1.15; }
SHOPPING WISH LISTS WITH FIREBASE (PART 1)
I have implemented wish list functionality into my store. Wish list functionality allows users to save and manage a list of desired items or products they may want to purchase in the future. By clicking the heart icon in the category view you can add products to, or remove them from the wish list individually, and by clicking the button on the cart page, all items in cart are added to the wish list. I use Firebase for this, which I discussed in the previous post. Below is the code for adding the buttons and handling the functionality. As these buttons are created dynamically, like many of my elements, I use a mutation observer to set their status if the user is logged in.
HTML:
JAVASCRIPT: <script type="module"> // init firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, collection, setDoc, writeBatch, getDocs, getDoc, doc, 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); // the educated wish document.addEventListener('DOMContentLoaded', function() { const subString = 'cart'; const subStringTwo = 'shop'; const subStringThree = 'shop/p/'; let getURL = window.location.href; let itemId = '0'; var inWishlist = false; let wishlist = []; let productName = "Product"; //Pop-Over init 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); } //Fetch wishlist async function getWishlist() { const user = auth.currentUser; if (!user) { console.error('User not authenticated when fetching wishlist'); return; } try { const wishlistRef = collection(db, 'users', user.uid, 'wishlist'); const querySnapshot = await getDocs(wishlistRef); wishlist = querySnapshot.docs.map(doc => doc.id); // Get item IDs return wishlist; } catch (error) { console.error('Error fetching wishlist items:', error); return []; // Return an empty array in case of an error } } // add or delete itemID in wishlist on firebase async function addToWishlist(itemId) { const user = auth.currentUser; if (!user) { console.error('User not authenticated'); window.location.href = '/login'; return; } try { const wishlistRef = collection(db, 'users', user.uid, 'wishlist'); const itemDocRef = doc(wishlistRef, itemId); const docSnap = await getDoc(itemDocRef); if (docSnap.exists() && !getURL.includes(subString)) { await deleteDoc(itemDocRef); $('#popoverMessage').off('click'); popoverMessage.addEventListener('click', function() { window.location.href = '/wishlist'; }); popoverMessage.style.color = "#ea4b1a"; showPopover(productName + ' Undesired'); } else { await setDoc(itemDocRef, { itemId: itemId }); $('#popoverMessage').off('click'); popoverMessage.addEventListener('click', function() { window.location.href = '/wishlist'; }); popoverMessage.style.color = "#ffc700"; showPopover(productName + ' Desired'); } } catch (error) { console.error('Error adding item to wishlist:', error.message); } } // Fetch item ID from JSON and add to wishlist async function fetchItemIdAndAddToWishlist(jsonURL) { 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) { productName = "Acquisitions"; await addToWishlist(itemId); } else { console.error('Item ID not found in JSON data'); } } catch (error) { console.error('Error fetching or processing JSON data:', error.message); } } // get cart items info (json link) function getCartContents() { const cartContainer = document.querySelector('.cart-container'); const cartContents = cartContainer.querySelectorAll('.cart-row'); cartContents.forEach(cartRow => { // construct the json url of each product const productDesc = cartRow.querySelector('.cart-row-desc'); const productLinkElement = productDesc.querySelector('a'); const productLink = productLinkElement.getAttribute('href'); const baseURL = 'https://www.polivantage.com'; const jsonURL = `${baseURL}${productLink}?format=json-pretty`; fetchItemIdAndAddToWishlist(jsonURL); }); } if (getURL.includes(subString)) { // Ensure the button is always in the correct place function observeCartContainer() { const observer = new MutationObserver((mutationsList) => { for (let mutation of mutationsList) { if (mutation.type === 'childList') { const addedNodes = Array.from(mutation.addedNodes); addedNodes.forEach(node => { if (node.nodeType === 1 && node.classList.contains('cart-container')) { const existingWishBtns = document.querySelectorAll('.wishlistButton'); if (existingWishBtns.length > 0) { existingWishBtns.forEach(element => { element.remove(); }); } // Create the save to wishlist from cart button const wishlistButton = document.createElement('button'); wishlistButton.id = "wishlistButton"; wishlistButton.classList.add('wishlistButton', 'sqs-block-button-element--medium', 'sqs-block-button-container', 'sqs-button-element--primary', 'sqs-block-button-element'); wishlistButton.type = 'button'; wishlistButton.textContent = 'Add to Wish List'; const lastChild = document.querySelector('.cart-container > :last-child'); if (lastChild) { lastChild.insertAdjacentElement('beforebegin', wishlistButton); } wishlistButton.addEventListener('click', function() { getCartContents(); }); } }); // check for the button to be in right place if (mutation.removedNodes.length > 0) { mutation.removedNodes.forEach(node => { if (node.nodeType === 3) { // Node type 3 is a text node const lastChild = document.querySelector('.cart-container > :last-child'); if (lastChild) { lastChild.insertAdjacentElement('beforebegin', wishlistButton); } } }); } } } }); observer.observe(document.body, { childList: true, subtree: true }); } observeCartContainer(); } else if (getURL.includes(subStringTwo)) { //set intial like button status onAuthStateChanged(auth, async (user) => { if (user) { try { await getWishlist(); function handleHeartDivs() { let allHearts = document.querySelectorAll('.heartDiv'); allHearts.forEach(heart => { let itemId; if (!getURL.includes(subStringThree)) { const gridItem = heart.closest('.grid-item'); itemId = gridItem ? gridItem.getAttribute('data-item-id') : null; } else { const productItem = heart.closest('.ProductItem'); itemId = productItem ? productItem.getAttribute('data-item-id') : null; } if (itemId && wishlist.includes(itemId)) { heart.classList.add('wished'); } else { heart.classList.remove('wished'); } }); } handleHeartDivs(); const observer = new MutationObserver((mutationsList) => { for (let mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && node.matches('.heartDiv')) { handleHeartDivs(); } else if (node.querySelectorAll) { node.querySelectorAll('.heartDiv').forEach(heart => { handleHeartDivs(); }); } }); } } }); observer.observe(document.body, { childList: true, subtree: true }); } catch (error) { console.error('Error getting wishlist on auth', error); } } }); // create the quick wishlist icons let allButtonWrappers = document.querySelectorAll('.plp-grid-add-to-cart'); allButtonWrappers.forEach(function(wrapper) { const heartDiv = document.createElement('div'); heartDiv.id = "heartDiv"; heartDiv.classList.add('like-icon', 'heartDiv'); wrapper.insertBefore(heartDiv, wrapper.firstChild); // add to wishlist through quick icon heartDiv.addEventListener('click', function() { // if in category view if (!getURL.includes(subStringThree)) { const gridItem = heartDiv.closest('.grid-item'); const gridItemLink = gridItem.querySelector('a'); productName = gridItemLink.textContent.replace(/€\d+(\.\d+)?/g, '').replace(/[★☆]/g, ''); itemId = gridItem.getAttribute('data-item-id'); } else { // if in product view const productItem = heartDiv.closest('.ProductItem'); const gridItemLink = productItem.querySelector('a'); productName = gridItemLink.textContent.replace(/€\d+(\.\d+)?/g, '').replace(/[★☆]/g, ''); itemId = productItem.getAttribute('data-item-id'); } heartDiv.classList.toggle('wished'); addToWishlist(itemId); }); }); //end create buttons } }); </script>
CSS: .heartDiv.wished { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/81708e25-5ea7-489e-b938-457024b47773/hearticon1.png'); background-repeat: no-repeat; background-position: center; background-size: contain; } .heartDiv { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/805dad31-be5b-40f9-9aa9-309c712f7510/hearticonEmpty1.png'); border: 0px !important; width: 25px !important; height: 25px !important; bottom: 85px; right: 5px; position: absolute; padding-left: 10px; pointer-events: auto; cursor: pointer; background-repeat: no-repeat; background-position: center; background-size: contain; transition: all 0.6s ease; scale: 1; z-index: 9999; } .heartDiv:hover { transition: all 0.3s ease; scale: 1.3; } //cart wishlist button .wishlistButton { -webkit-tap-highlight-color: transparent; -moz-tap-highlight-color: transparent; display: block; cursor: pointer; padding-right: 15px !important; padding-top: 10px !important; padding-bottom: 40px !important; margin-top: 30px; min-width: 335px; height: 50px; color: #000000; letter-spacing: 2.5px !important; font-size: 20px; text-align: right !important; position: relative; @media only screen and (min-width:790px) { transform: translateX(0%) translateY(0%) !important; } @media only screen and (max-width:790px) { width: 100%; padding-bottom: 45px !important; padding-left: 44% !important; margin-bottom: -5px; } }
This post has been updated to reflect a more complete overview of the recently added functionality and other improvements.
Accounts and user collections with “firebase” (Redundant)
This is going to be more of an article and a proof of concept, rather than a ready made implementation. Some hours and slightly less than a 1000 lines of code later, I implemented custom, fully customizable and extendable user accounts, and the ability to save liked images and display them in your own, personal gallery. These collections are stored, along with the password and the unique user identifier on the server and are infinitely retrievable, unlike javascript’s session, or local storage. I achieved this by implementing Firebase. Firebase is a cloud service which allows secure storage of data and secure authentication for apps and web content. It has other features too, but I am only using authentication and storage. The way this works is - After a bit of setup on the Firebase portal side, I import special Firebase commands into my scripts, and use them to handle user requests, such as submission of custom made forms, and the liking of images. I store and retrieve data from the servers to update front end statuses and display the right content. Currently you can create an account by clicking the horned icon in the main menu and then like and unlike any images in my art section by clicking the little heart in the right bottom corner of each image. This information is directly synced with the servers. Subsequently you can view these images in your own gallery, ominously dubbed the “judgment den”. I plan to implement wish lists for my shop next. Below you will find excerpts of code from my login authentication form, that illustrates my workflow. These excerpts are not useful for copy and pasting. Unfortunately there is too much customized code to make this entry into a ready made solution for others, but I might do an in-depths piece about Firebase later, as it was very doable to implement, with zero prior knowledge of how any databases work. The code below illustrates well the structure of implementing Firebase in your scripts, I hope that at least that will be useful to you.
HTML: <div id="loginContainer" class="formContainer"> <form id="login-form"> <div id="loginFieldsContainer" class="formFieldsContainer"> <div class="formFieldWrapper"> <div class="nameDescription description"> <p>E-mail:</p> </div> <input type="email" id="login-email" class="formfield emailField" placeholder="E-mail" required> </div> <div class="formFieldWrapper"> <div class="nameDescription description"> <p>Password:</p> </div> <input type="password" id="login-password" class="formfield passwordField" placeholder="Password" required> </div> </div> <div id="loginButtonContainer" class="buttonContainer sqs-block-button-container"> <button type="submit" class="formButton sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element">Log in</button> </div> <div id="login-error-message" class="error-message"></div> </form> </div>
JAVASCRIPT: // Initialize Firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; //here you import the commands which you will be using in your script import { getAuth, onAuthStateChanged, signInWithEmailAndPassword } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; const firebaseConfig = { //personal information below has been sanitized apiKey: "------", authDomain: "-----", projectId: "-----------", storageBucket: "-----------", messagingSenderId: "---------------", appId: "--------------------", measurementId: "--------------" }; const app = initializeApp(firebaseConfig); const auth = getAuth(app); //the usual code follows document.addEventListener('DOMContentLoaded', function() { const loginForm = document.getElementById('loginContainer'); const subString = 'login'; const getURL = window.location.href; if (getURL.includes(subString)) { // Check if the user is already logged in onAuthStateChanged(auth, (user) => { //notice first use of firebase command if (user) { window.location.href = '/usergallery'; } else { //embed the form const formSection = document.querySelector('[data-section-id="66b9e5ec1538141a71419ab3"]'); const formSectionContent = formSection.querySelector('.content'); formSectionContent.appendChild(loginForm); // Handle form submission document.getElementById('login-form').addEventListener('submit', function(event) { event.preventDefault(); const email = document.getElementById('login-email').value; const password = document.getElementById('login-password').value; signInWithEmailAndPassword(auth, email, password) .then((userCredential) => { // User signed in successfully window.location.href = '/usergallery'; // Redirect to a logged-in page }) .catch((error) => { // Handle errors let errorMessage = 'An error occurred. Please try again.'; if (error.code === 'auth/wrong-password') { errorMessage = 'Incorrect password.'; } else if (error.code === 'auth/user-not-found') { errorMessage = 'No user found with this e-mail.'; } else if (error.code === 'auth/invalid-email') { errorMessage = 'The e-mail address is not valid.'; } document.getElementById('login-error-message').textContent = errorMessage; }); }); } }); } else { loginForm.remove(); } });
CSS: .formContainer { display:flex; flex-wrap: wrap; justify-content:center; width: 100%; height: auto; margin: 0; padding: 0; top: 40px; position: absolute; } .formFieldsContainer { display: flex; flex-wrap: wrap; justify-content: center; width: 100%; } .formFieldWrapper p { text-align: left; padding: 0; padding-left: 10px; margin: 0; } .formfield { margin: 0; margin-left: 10px; margin-right: 10px; margin-bottom: 20px; min-width: 350px; height: 30px; border: 0; border-bottom: 2px; background: transparent; } .formButton { width: 100%; text-align: right; letter-spacing: 4px !important; } @media screen and (max-width:790px) { .formButton { width: 88%; } } .error-message { text-align: center; padding: 20px; letter-spacing: 1.6px; }
Display Latest Shop products in summary block
This can be achieved by simply manually moving the last product you add to your shop, to the top of the list in the main shop category. However, if you have many products in your store it soon becomes a chore to do this every time you add a new product. I wrote a bit of code to modify all store related summary blocks so that the order is reversed. Now the last product in your shop’s main category list is displayed first. This can probably be also achieved by creating your own summary block from scratch, as the resulting modification completely restructures the HTML within the summary block and requires a lot of styling to gain a respectable look. I am using “?format=json-pretty” appended after the url of my shop link to access the product list through JSON, and read the attributes of individual products.
HTML:
JAVASCRIPT: document.addEventListener('DOMContentLoaded', function() { function updateSummaryBlock(container, products) { container.innerHTML = ''; products.forEach(product => { let productHTML = ` <div class="summaryProductItem"> <a href="${product.fullUrl}"> <img src="${product.items[0].assetUrl}" alt="${product.title}" class="summaryBlockImage"> <p class="summaryBlockProductTitle">${product.title}</p> </a> </div> `; container.insertAdjacentHTML('beforeend', productHTML); const summaryBlockImages = document.querySelectorAll('.summaryBlockImage'); summaryBlockImages.forEach(image => { image.style.opacity = '1'; }); }); } let summaryBlockContainers = document.querySelectorAll('.summary-block-wrapper'); if (summaryBlockContainers.length > 0) { summaryBlockContainers.forEach(function(container) { const isProductBlock = container.classList.contains('summary-block-collection-type-products'); if (isProductBlock) { const jsonShopUrl = 'https://www.polivantage.com/shop?format=json-pretty'; fetch(jsonShopUrl) .then(response => response.json()) .then(data => { const products = data.items; products.reverse(); updateSummaryBlock(container, products.slice(0, 3)); }) .catch(error => console.error('Error fetching products:', error)); } }); } });
CSS: .sqs-block-summary-v2 { height: auto; } .summary-item-list { display:flex; flex-wrap: wrap; justify-content:center; width: 100%; } .summary-item-list-container { width: 100%; } .summaryProductItem { transition: all 0.55s ease; scale: 1; } .summaryProductItem:hover { transition: all 0.3s ease; scale: 1.05; } .summary-block-wrapper * { margin: 5px; } .summary-block-wrapper { display:flex; flex-wrap: nowrap; justify-content:center; margin: 0; } @media screen and (max-width:790px) { .summary-block-wrapper { flex-wrap: wrap; } .summary-block-wrapper * { margin: 2px; } } .summary-title, .summaryBlockProductTitle { text-transform: uppercase; letter-spacing: 2.5px; font-size: 16px; text-align: center; } .summaryBlockImage, .summary-thumbnail-image { width: 350px; height: 350px; border: solid 2px black; }
Please note that the CSS also affects other product blocks and ensures centering of the items within.
Adding Live Search to Shop
I first added a live search, or instant search to my blog and now to my shop. This type of search modifies page contents based on the query, instead of directing you to a new page with the results. You can see it in action if you click the magnifier icon in my shop above the categories, and begin typing. The code filters the product listing based on the search query, and hides all the grid items (shop products) if the query does not match anything found in the title or the product tags. There is some additional code to handle the icon sliding left and right, but this is purely cosmetic. The key difference from the live search in my blog, is that I now not only look at the title of the product, which is housed in an attribute, but also at the product tags. These tags are listed as classes and preceded by the word “tag-”. You will find some functionality to extract this information below. I did not parse any JSON to achieve this, instead I handled everything inside the attributes as plain text.
HTML: <input type="text" id="shopSearchInput" class="shop-search-input" placeholder="">
JAVASCRIPT: document.addEventListener('DOMContentLoaded', function() { const subString = 'shop'; const badString = 'shop/p/'; const getURL = window.location.href; const shopSearchInput = document.querySelector('#shopSearchInput'); if (getURL.includes(subString) && !getURL.includes(badString)) { var mobileMenuActive = false; let isTouch = false; const headerActions = document.querySelector('.header-actions'); function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); //embed the shop search bar var nestedCategories = document.querySelector('.nested-category-tree-wrapper'); if (isTouch == true) { nestedCategories.insertBefore(shopSearchInput, nestedCategories.lastChild); shopSearchInput.classList.add('mobile'); } else { nestedCategories.insertBefore(shopSearchInput, nestedCategories.firstChild); shopSearchInput.classList.remove('mobile'); } //handle shop search const debounceDelay = 700; let debounceTimeout; shopSearchInput.addEventListener('input', function() { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const query = this.value.toLowerCase(); //if input is not empty scroll to top if (query.trim() !== '' && isTouch == false) { setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 50); } const products = document.querySelectorAll('.grid-item'); products.forEach(product => { const contextData = product.getAttribute('data-current-context'); let title = ''; if (contextData) { const titleMatch = contextData.match(/"title":\s*"([^"]+)"/); title = titleMatch ? titleMatch[1].toLowerCase() : ''; } const tagClasses = Array.from(product.classList) .filter(cls => cls.startsWith('tag-')) .map(cls => cls.replace('tag-', '').toLowerCase()); const matchesTitle = title.includes(query); const matchesTag = tagClasses.some(tag => tag.includes(query)); if (matchesTitle || matchesTag) { product.style.display = ''; // Show the product } else { product.style.display = 'none'; // Hide the product } }); }, debounceDelay); }); //is input empty for cosmetics var hasText = false; function checkInput() { hasText = shopSearchInput.value.trim() !== ''; } document.addEventListener('click', function(event) { checkInput(); if (event.target !== shopSearchInput && hasText == false) { shopSearchInput.classList.remove('texted'); } else { shopSearchInput.classList.add('texted'); } }); } else { shopSearchInput.remove(); } });
CSS: #shopSearchInput { width: 200px; height: 50px !important; display: block; background: transparent; border: 0 !important; font-size: 1.3rem; letter-spacing: 1.8px; outline: none; padding: 0; margin: 0; margin-bottom: 28px; margin-left: -50px; text-align: left; z-index: 999; transition: all .7s ease !important; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/2b60bbec-4d1c-4d73-9d6a-009b9bf1d26a/SearchCrosshairIcon.png); background-size: 50px; background-position: bottom 0px left 38px; background-repeat: no-repeat; padding-left: 50px !important; } @media screen and (max-width:790px) { #shopSearchInput { position: absolute; width: 70%; background-position: bottom 0px left 55%; margin-left: 0px; top: 429px; } } #shopSearchInput:focus { outline: none; transition: all .7s ease; background-position: bottom 0px left -60px; } #shopSearchInput.texted { outline: none; transition: all .7s ease; background-position: bottom 0px left -6px; }
Adding Live Search to Blog
I added a live search, or instant search to my blog. This type of search modifies page contents based on the query, instead of directing you to a new page with the results. You can see it in action if you click the magnifier icon below the title of my blog and begin typing. In my case the code filters the blog post container based on the search query and hides all the list items (blog post titles) which do not contain the query. There is some additional code to handle the icon sliding left and right, based on whether there is text in the input field, but this is purely cosmetic.
HTML: <div id="blogSearchInputContainer" class="blogSearchInputContainer"> <input type="text" id="blogSearchInput" class="blog-search-input" placeholder=""> </div>
JAVASCRIPT: document.addEventListener('DOMContentLoaded', function() { const subString = 'devlog'; const getURL = window.location.href; const blogSearchInputContainer = document.querySelector('#blogSearchInputContainer'); if (getURL.includes(subString)) { //embed the search bar const blogTitle = document.querySelector('#block-yui_3_17_2_1_1723055432501_22056'); blogTitle.insertAdjacentElement("afterend", blogSearchInputContainer); //handle search const blogSearchInput = document.querySelector('#blogSearchInput'); const debounceDelay = 700; let debounceTimeout; blogSearchInput.addEventListener('input', function() { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const titles = document.querySelectorAll('.custom-post-title'); const query = this.value.toLowerCase(); titles.forEach(title => { const titleText = title.textContent.toLowerCase(); const listItem = title.closest('li'); if (titleText.includes(query)) { listItem.style.display = ''; } else { listItem.style.display = 'none'; } }); }, debounceDelay); }); //clear input if click outside of search depending on input value var hasText = false; function checkInput() { hasText = blogSearchInput.value.trim() !== ''; } document.addEventListener('click', function(event) { checkInput(); if (event.target !== blogSearchInput && hasText == false) { blogSearchInput.classList.remove('texted'); } else { blogSearchInput.classList.add('texted'); } }); } else { blogSearchInputContainer.remove(); } });
CSS: #blogSearchInputContainer { max-width: 2200px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; height: 50px; margin: 0; padding: 0; top: -10px; } #blogSearchInput { width: 200px; height: 50px !important; display: block; background: transparent; border: 0 !important; font-size: 1.3rem; letter-spacing: 1.8px; outline: none; padding: 0; margin: 0; text-align: center; z-index: 999; transition: all .7s ease !important; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/2b60bbec-4d1c-4d73-9d6a-009b9bf1d26a/SearchCrosshairIcon.png); background-size: 50px; background-position: bottom 0px left 50%; background-repeat: no-repeat; padding-left: 50px !important; } #blogSearchInput:focus { outline: none; transition: all .7s ease; background-position: bottom 0px left -6px; } #blogSearchInput.texted { outline: none; transition: all .7s ease; background-position: bottom 0px left -6px; }
Typewriter effect
The following code implements a typewriter effect for headings inside my blog. I took extra steps to ensure that the typing starts from the very beginning of the title - there is a slight delay to ensure the elements become visible first, there is a phantom element that ensures there are no jumps and stutters in spacing as the text is being manipulated, and the opacity is managed timely to ensure a proper visual presentation.
HTML:
JAVASCRIPT: document.addEventListener('DOMContentLoaded', function() { const subString = 'developmentlog'; const getURL = window.location.href; if (getURL.includes(subString)) { const headings = document.querySelectorAll('h1, h2, h3, h4'); if (headings.length > 0) { headings.forEach((heading, index) => { const typedText = heading.innerText; let i = 0; const placeholder = document.createElement('span'); placeholder.innerText = typedText; placeholder.style.visibility = 'hidden'; heading.innerHTML = ''; heading.appendChild(placeholder); function typeWriter() { placeholder.style.display = 'none'; heading.style.opacity = '1'; if (i < typedText.length) { heading.innerHTML += typedText.charAt(i); i++; setTimeout(typeWriter, 70 + (index * 50)); } } setTimeout(typeWriter, 800); }); } } });
CSS:
Display Page publishing date
The following code simply adds the publishing date of any page (including the year), in my case of my blog posts, extracted from the page’s meta tag. The format is customizable, - I prefer written out months. I insert it under the blog body, but you can find another place for it if you so desire.
HTML:
JAVASCRIPT: var publishDateMeta = document.querySelector('meta[itemprop="datePublished"]'); var publishDate = publishDateMeta.getAttribute('content'); var dateElement = document.createElement('p'); var options = { year: 'numeric', month: 'long', day: 'numeric' }; var formattedDate = new Date(publishDate).toLocaleDateString('en-US', options); dateElement.innerText = '' + formattedDate; dateElement.classList.add('blogPublishDate'); //Find a place to insert the element: //blogBody.insertBefore(dateElement, blogBackButtonContainer); //or console log it: console.log(dateElement.innerText);
CSS: .blogPublishDate { text-align: center; }
Please note that I embed this code into another piece of code and as such it lacks the DOM content loaded check. Please take care.
Easy CODE Mark-Up WITH “HIGHLIGHT.JS”
I wrote 29 blog posts before I bothered to think of actually making the code look presentable. I am embedding the code I give in these posts into code blocks, and ticking on the “display code” checkbox. I didn’t want to change all those 29 posts retrospectively to have code mark-up and I definitely didn’t want to style every mark-up situation with CSS. The solution was to implement Highlight.js and write a piece of code to automatically wrap the desired code in the right tags so that Highlight.js can do its magic. My code is always preceded by the text “HTML”, “JAVASCRIPT”, or “CSS”, on the first line of the code blocks. This was the only identifier for the scripting language that I had as the squarespace code blocks do not add separate classes based on the type of code that is inside the block, as far as I could find. Below is a piece of code that allowed me to use code mark-up with minimal effort considering the above. Please note that the Javascript goes into the Footer section, and only the HTML goes into the Header section.
HTML: <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/an-old-hope.min.css"> <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
JAVASCRIPT: document.addEventListener("DOMContentLoaded", function() { //Check if we are in blog const subString = 'developmentlog'; const getURL = window.location.href; if (getURL.includes(subString)) { //Find all elements with the class 'source-code' var codeBlocks = document.querySelectorAll('.source-code'); codeBlocks.forEach(function(block) { var codeText = block.textContent.trim(); var language = 'plaintext'; // Default language //Extract the first line to detect the language var firstLineEndIndex = codeText.indexOf('\n'); var firstLine = firstLineEndIndex === -1 ? codeText : codeText.substring(0, firstLineEndIndex).trim(); //Map common language names to Highlight.js classes var languageMap = { 'html:': 'html', 'javascript:': 'javascript', 'css:': 'css', }; //Detect the language if the first line is a known language indicator if (languageMap[firstLine.toLowerCase()]) { language = languageMap[firstLine.toLowerCase()]; //Remove the "//" to remove the first line from the code text // codeText = firstLineEndIndex === -1 ? '' : codeText.substring(firstLineEndIndex + 1).trim(); } // Create a <pre> element and <code> element var pre = document.createElement('pre'); var code = document.createElement('code'); code.className = 'language-' + language; // Add language class to <code> //Set the remaining code text into the <code> block code.textContent = codeText; pre.appendChild(code); //Replace the source-code block with the newly created <pre><code> block block.parentNode.replaceChild(pre, block); }); //Initialize Highlight.js to apply syntax highlighting hljs.highlightAll(); } });
CSS:
I have a check at the beginning to see if I am in the blog section. You can eliminate that for your purposes, or change the subString to the url slug of your blog. Hightlight.js has a plethora of themes, you can pick whatever suits your personal preference from their website - highlightjs.org. I am not affiliated with them, but I must say I very much appreciate what they did. Thank you Highlight.Js!
Sliding BLOG LIST Indicator GUN
If you browsed my blog posts on desktop, you noticed the sliding pistol indicator on the left. The following code enables that functionality. It is essentially a DIV with absolute positioning which through Javascript takes on the location of the node you are hovering over, with a slight time-out to prevent jitter. Since my blog is dynamically generated I had to use multiple observers. First one checks if the container element is added, the second one checks if the list is added, and another one checks for the blog titles inside that container, which subsequently become the hover targets for the pistol to move towards vertically. The code also handles offsets for dynamic image adjustment stored in variables, and accounts for CSS zoom as of the last update. It also only reveals the indicator when it has assumed its position near the first list entry. Currently this code is used for list entries inside my blog container. It can be easily adapted, however, to monitor other classes and slide towards those, if you want to use it elsewhere on the website.
HTML: <div id="gunindicator" class="gunindicator"></div>
JAVASCRIPT: document.addEventListener("DOMContentLoaded", function() { const subString = '/devlog'; const getURL = window.location.href; const indicator = document.querySelector(".gunindicator"); const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (getURL.includes(subString) && isTouch == false) { const leftOffset = 90; const topOffset = 12; let indicatorSet = false; let initialContainer; let hoverTimeout; function updateIndicator(link) { const zoomFactor = parseFloat(document.body.style.zoom) || 1; const rect = link.getBoundingClientRect(); const offsetTop = rect.top + window.scrollY - topOffset; const offsetLeft = rect.left - leftOffset; const linkHeight = rect.height; // Adjust the indicator's position and height indicator.style.left = `${offsetLeft / zoomFactor}px`; indicator.style.top = `${offsetTop / zoomFactor}px`; indicator.style.height = `${linkHeight / zoomFactor}px`; } function showIndicator() { indicator.style.opacity = '1'; indicator.style.visibility = 'visible'; indicator.style.transform = "rotate(0deg)"; } function addHoverEffect(link) { link.addEventListener("mouseover", function() { indicator.style.transform = "rotate(-45deg)"; clearTimeout(hoverTimeout); hoverTimeout = setTimeout(() => { updateIndicator(link); indicator.style.transform = "rotate(0deg)"; }, 350); // prevent jittering by delay before focus on link target }); } function attachHoverToAllLinks(container) { const links = container.querySelectorAll("li"); links.forEach(link => { addHoverEffect(link); if (!indicatorSet) { updateIndicator(link); showIndicator(); indicatorSet = true; } }); } function setupObserverForList(container) { const listObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && node.matches("li")) { console.log("li added: ", node); // Debugging line addHoverEffect(node); } }); } } }); listObserver.observe(container, { childList: true, subtree: true }); } function setupObserver(container) { const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && node.matches("ol")) { attachHoverToAllLinks(node); setupObserverForList(node); } }); } } }); observer.observe(container, { childList: true, subtree: true }); } function handleBlogPostsContainer(container) { setupObserver(container); const firstList = container.querySelector("ol"); if (firstList) { attachHoverToAllLinks(firstList); // Attach hover to any initial `li` elements setupObserverForList(firstList); // Watch for `li` elements in the `ol` } } function monitorForBlogPostsContainer() { const bodyObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && node.matches("#blogPostsContainer")) { handleBlogPostsContainer(node); } }); } } }); bodyObserver.observe(document.body, { childList: true, subtree: true }); initialContainer = document.querySelector("#blogPostsContainer"); if (initialContainer) { handleBlogPostsContainer(initialContainer); } } monitorForBlogPostsContainer(); } else { indicator.remove(); } });
CSS: .gunindicator { visibility: hidden; opacity: 0; position: absolute; width: 65px !important; height: 65px !important; background: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/8f775caa-0441-4725-ac44-b007c4bed482/pistolIcon3.png') no-repeat center center; background-size: contain; transition: all 1s ease; z-index: 99999; }
If your elements are not dynamically added and are present when the DOM is loaded, you won’t need to make use of observers and the code will be very short.
All blog Posts on A SINGLE page
So Squarespace has a limit of 20 blog post listings per page. This can be bumped up to 30 using summary blocks. I found this unsatisfactory and hence, here is my solution to load all of them onto a single page, or as many as you like. Be warned, this is a heavy operation and the loading times are quite long. Maybe this is why squarespace only allows 20. Please note that to my surprise squarespace allows accessing JSON data! This can be done by appending “?format=json-pretty”
to your link, in this case the link of your blog. This is important for the code to function. Also note that by setting the maximum posts per page to 20 in your blog, you decrease the number of pages, which should decrease loading times of this script as it fetches the content.
HTML: <div id="blogPostsContainer" class="blogPostsContainer"></div>
JAVASCRIPT: document.addEventListener('DOMContentLoaded', function() { const subString = 'devlog'; const getURL = window.location.href; //const contentWrapper = document.querySelector('.content-wrapper'); const blogSection = document.querySelector('[data-section-id="66b4124ebc499f1778bfe1ea"]'); const blogPostsContainer = document.getElementById('blogPostsContainer'); const baseUrl = 'https://www.polivantage.com/developmentlog?format=json-pretty'; let pageNumber = 1; let allPosts = []; if (getURL.includes(subString)) { //Fetch my blog function loadAllPosts() { fetch(`${baseUrl}&page=${pageNumber}`) .then(response => response.json()) .then(data => { const posts = data.items; allPosts = allPosts.concat(posts); // Check if there are more pages if (data.pagination && data.pagination.nextPage) { pageNumber++; loadAllPosts(); } else { displayPosts(allPosts); } }) .catch(error => console.error('Error fetching posts:', error)); } //Display all post titles in a list function displayPosts(posts) { blogPostsContainer.innerHTML = ''; // Clear the container before adding new posts const list = document.createElement('ol'); // Create an ordered list // Use the original posts array to display the oldest posts first posts.forEach((post, index) => { const postItem = document.createElement('li'); const postNumber = posts.length - index; // Adjust the number to show the latest post first postItem.innerHTML = ` <h4><a href="${post.fullUrl}" class="custom-post-title">${postNumber}. ${post.title}</a></h4> <p>${post.excerpt}</p>`; list.appendChild(postItem); }); const contentWrapper = blogSection.querySelector('.content-wrapper'); blogPostsContainer.appendChild(list); contentWrapper.appendChild(blogPostsContainer); //prevent resizing during modification of list such as on search blogPostsContainer.style.minWidth = `${list.offsetWidth}px`; } loadAllPosts(); } else { blogPostsContainer.remove(); } });
CSS: //remove stock content section[data-section-id="66b4124ebc499f1778bfe1ea"] .content-wrapper .content { width: 0 !important; } //get rid of stock list numbers section[data-section-id="66b4124ebc499f1778bfe1ea"] ol { list-style-type: none; } #blogPostsContainer { max-width: 2200px; display: block; position: relative; left: 0; top: 0; margin: 0px; padding: 0px; } .custom-post-title { // transition: all 2s ease; } .custom-post-title:hover { border-bottom: 2px solid; border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%, rgba(0,0,0,1) 50%, rgba(0,0,0,1) 80%, rgba(0,0,0,0) 100%); border-image-slice: 1; }
The section ID is where your blog will be loaded. You can style it from here.
Remove Stock Spinner Wheel Background
When adding or removing products, while in the cart page, using the quantity inputs, there appeared a pesky spinner-wheel with an even more annoying grey overlay background, which I couldn’t style for many a days. I desperately wanted to get rid of it. Eventually I wrote a piece of code to log the briefly appearing elements and extracted class names from there. I kept the spinner-wheel, as it looked decent after getting rid of the grey overlay. The CSS alone is used to disable the grey overlay, while the provided Javascript code is for you to be able to console.log your own elements on your site, which appear and disappear too quickly to actually get their class name. Please note that such jumbled up class names are seemingly assigned randomly by squarespace, as a result, after some time you will have to run the code again and change the CSS, because the class names will change. I haven’t found a better solution yet, will update this post when I do.
HTML:
JAVASCRIPT: <!--LOG NEW ELEMENTS--> <script> // Ensure the DOM is fully loaded before setting up the observer document.addEventListener('DOMContentLoaded', (event) => { // Create a callback function to execute when mutations are observed const callback = (mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { // Loop through the added nodes mutation.addedNodes.forEach(node => { // Check if the node is an element (not a text node, etc.) if (node.nodeType === Node.ELEMENT_NODE) { console.log(`New element added: ID = ${node.id}, Class = ${node.className}`); } }); } } }; // Create an observer instance linked to the callback function const observer = new MutationObserver(callback); // Start observing the document body for configured mutations const targetNode = document.body; if (targetNode) { observer.observe(targetNode, { childList: true, subtree: true }); } else { console.error('Target node for MutationObserver is not available.'); } // Optionally, you can disconnect the observer when you no longer need it // observer.disconnect(); }); </script>
CSS: //spinner wheel background .IxjCAUd2hJFV_2zxKcW9, .tTLeCwDAKFm1AX2fwAaS, .pFXZ5G2whUcWOosr649g { background: transparent !important; }
Note that the JS included is only for you to log your own elements which are too fleeting for inspection. The CSS classes may differ on your website.
COMMENTS:
Leave a comment: