Development Log

Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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;
}
Read More
Lev Lev

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.

Read More
Lev Lev

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;
}
Read More
Lev Lev

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.

Read More
Lev Lev

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;
}
Read More
Lev Lev

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.

Read More
Lev Lev

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;
}  
Read More
Lev Lev

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;
} 
Read More
Lev Lev

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:

Read More
Lev Lev

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.

Read More
Lev Lev

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!

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More