Files
Quick-View/app.js
atomkiv b9d6985ec7 Add app.js
Quick View logic is here
2025-10-02 14:47:19 +02:00

623 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class ProductQuickView {
constructor() {
this.product = null;
this.size = null;
this.idmProductId = null;
this.basketSubmitted = false;
this.htmlQuickView = '';
this.htmlEl = document.querySelector('html');
}
// Escapes HTML to prevent XSS in outputs
escapeHtml(v = '') {
return String(v)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '');
}
// Main listener to set up product quick view and handlers after DOM load
init = () => {
const self = this;
document.addEventListener("DOMContentLoaded", async () => {
const productImgLinks = document.querySelectorAll('#search .product');
const prodInfoContainer = document.querySelector('.prod-info__container');
const asideProdMenu = document.querySelector('.right-aside');
const customAdded = document.getElementById('quick_view_basket');
productImgLinks.forEach(prod => this.initProductCard(prod, prodInfoContainer, asideProdMenu, customAdded));
this.renderProduct(asideProdMenu, prodInfoContainer, customAdded);
});
}
// Add quick view card buttons, setup close listeners
initProductCard(prod, prodInfoContainer, asideProdMenu, customAdded) {
const cardPictureContainer = document.createElement('div');
if (!prod.querySelector('.hover-btn') && !prod.querySelector('.card__picture-container')) {
cardPictureContainer.className = 'card__picture-container';
prod.insertAdjacentElement('afterbegin', cardPictureContainer);
const productContentWrapper = prod.querySelector('.product__content_wrapper');
const closeRightAsideBtn = document.querySelector('.right-aside__close');
const closeRightAsideBlur = document.querySelector('.right-aside__bg-blur');
const continueShopping = document.querySelector('.added__button.--close');
continueShopping.addEventListener('click', () => this.closeRightAside(prodInfoContainer, asideProdMenu));
closeRightAsideBtn.addEventListener("click", () => this.closeRightAside(prodInfoContainer, asideProdMenu));
closeRightAsideBlur.addEventListener("click", () => this.closeRightAside(prodInfoContainer, asideProdMenu));
const btn = document.createElement('button');
btn.className = 'hover-btn';
btn.textContent = 'CHOOSE OPTION';
prod.insertAdjacentElement('afterbegin', btn);
// Add button for mobile card
if (productContentWrapper && productContentWrapper.parentElement) {
const mobileBtn = btn.cloneNode(true);
productContentWrapper.insertAdjacentElement('afterend', mobileBtn);
}
}
// Attach anchors and buttons to quick view container
const link = prod.querySelector('a');
const button = prod.querySelector('button');
if (link) cardPictureContainer.appendChild(link);
if (button) cardPictureContainer.appendChild(button);
}
// Animate/Close quick view sidebar, handle basket state
closeRightAside(prodInfoContainer, asideProdMenu) {
asideProdMenu.classList.remove('active');
setTimeout(() => {
this.htmlEl.classList.remove('activeAside');
}, 300);
if (this.basketSubmitted) {
const rightAsideProdInfo = document.querySelector('.right-aside__prod-info');
const navRightAside = document.querySelector('.right-aside__nav');
const customAdded = document.getElementById('quick_view_basket');
rightAsideProdInfo.style.position = 'fixed';
navRightAside.style.position = 'static';
this.basketSubmitted = false;
prodInfoContainer.classList.remove("hidden");
customAdded.classList.add("hidden");
}
}
// Set up main product quick view handler on search area
renderProduct(asideProdMenu, prodInfoContainer, customAdded) {
document.getElementById("search")?.addEventListener("click", async (e) => {
const hoverBtn = e.target.closest(".hover-btn");
if (!hoverBtn) return;
e.stopPropagation();
this.htmlEl.classList.add("activeAside");
asideProdMenu.classList.add("active");
if (prodInfoContainer.hasChildNodes()) {
prodInfoContainer.innerHTML = "";
}
try {
this.idmProductId = Number(e.target.closest(".product").dataset.product_id);
await this.fetchQuickViewProduct(this.idmProductId, prodInfoContainer, customAdded);
} catch (err) {
console.error(err);
}
// Wait for dynamic HTML elements before binding option listeners
const interval2 = setInterval(() => {
const sizesContainer = document.querySelector('.idm-sizes');
const versionsContainer = document.querySelector('.idm-versions');
if (sizesContainer && versionsContainer) {
clearInterval(interval2);
this.bindOption(prodInfoContainer, sizesContainer, 'currentSize', this.product.sizes, 'currentSize', 'size-choose', 'size', true);
this.bindOption(prodInfoContainer, versionsContainer, 'currentVersion', this.product.group.versions, 'currentVersion', 'version-choose', 'product', false);
}
}, 100);
});
}
// Fetch product data and build modal quick view HTML
async fetchQuickViewProduct(idmProductId, prodInfoContainer, customAdded){
const query = `
query {
product(productId: ${idmProductId}) {
product{
id
name
link
icon
enclosuresImages{ url }
producer { id name link }
sizes {
id name code amount
price {
omnibusPrice {gross {formatted}}
crossedPrice {gross {formatted}}
beforeRebate {gross {formatted}}
beforeRebateDetails {youSavePercent}
price {gross {formatted}}
}
availability{description}
}
group{
id
versions{
id name productIcon link iconSmall
}
}
price {
price { gross { formatted } }
}
}
}
}
`;
const response = await fetch("/graphql/v1/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const data = await response.json();
this.product = data.data.product.product;
this.size = this.product?.sizes?.[0];
prodInfoContainer.innerHTML = "";
this.htmlQuickView = `
<div class="img__container">
<img src="${
this.product?.enclosuresImages?.[0]?.url
? this.product.enclosuresImages[0].url
: this.product.icon
}" />
</div>
<div class="product-info__part">
<div class="names-info">
<p class="producer-name">${this.product.producer.name}</p>
<h5 class="product-name">${this.product.name}</h5>
</div>
<div class="price-info">
<div class="price__container">
<h5 class="idm__quick-view-price">
${this.product?.sizes?.[0]?.price?.price?.gross?.formatted}
${
this.product?.sizes?.[0]?.price?.crossedPrice?.gross?.formatted
? `<span class="price-crossed">${this.product?.sizes?.[0]?.price?.crossedPrice?.gross?.formatted}</span>`
: ""
}
<span class="price-note">brutto / szt</span>
</h5>
<span class="price-omnibus">
${this.product?.sizes?.[0]?.price?.omnibusPrice?.gross?.formatted
? `Najniższa cena z 30 dni przed obniżką: ${this.product?.sizes?.[0]?.price?.omnibusPrice?.gross?.formatted}`
: ""}
</span>
</div>
<div class="availability__container">
${this.size.amount > 3 ?
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#C9EAD4"/>
<circle cx="12" cy="12" r="8" fill="#10A443"/>
</svg>
`
: this.size.amount > 0 ?
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#FFE8BF"/>
<circle cx="12" cy="12" r="8" fill="#D28C13"/>
</svg>
`
: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#FFBEC7"/>
<circle cx="12" cy="12" r="8" fill="#ED001F"/>
</svg>
`
}
<p class="availability">
${this.product?.sizes?.[0]?.availability?.description}
</p>
</div>
</div>
<div class="all-sizes__container">
<div class="idm-sizes__container">
${
this.product.sizes && this.product.sizes.length > 1
? `<input type="hidden" name="currentSize" value="">
<p>Size: <span class="size-choose">${this.product.sizes[0].name}</span></p>` +
'<div class="idm-sizes">' +
this.product.sizes
.filter((s) => s.id !== "uniw")
.map((s, i) =>
`<button data-id="${s.id}" class="product-size${i === 0 ? ' active' : ''}">${s.name}</button>`
)
.join("") +
'</div>'
: ""
}
</div>
<div class="idm-versions__container">
${
this.product.group.versions && this.product.group.versions.length > 1
? `<input type="hidden" name="currentVersion" value="">
<p>Type: <span class="version-choose">${this.product.group.versions.filter((s)=>s.id === this.product.id)?.[0]?.name}</span></p>` +
'<div class="idm-versions">' +
this.product.group.versions
.filter((s) => s.id !== "uniw")
.sort((a, b) => a.name.localeCompare(b.name))
.map((s, i) =>
`<button data-id="${s.id}" class="product-version${this.product.id === s.id ? ' active' : ''}"><img src="${s.iconSmall}"></button>`
)
.join("") +
'</div>'
: ""
}
</div>
</div>
<div class='idm-products-banner__add-to-basket'>
${this.idmAddToBasket(this.product, this.size)}
</div>
<div class="view-details__container">
<a class="view-details" href="${this.product.link}">View Details</a>
</div>
</div>
`;
prodInfoContainer.insertAdjacentHTML("afterbegin", this.htmlQuickView);
// Button state may depend on async HTML render - poll for element
// const interval2 = setInterval(() => {
// const addToBasketButton = document.querySelector(
// ".btn.--solid.--medium.idm-products-banner__add-to-basket-button"
// );
// if (addToBasketButton) {
// clearInterval(interval2);
// if (!this.size.amount) {
// addToBasketButton.classList.add("inactive");
// } else {
// addToBasketButton.classList.remove("inactive");
// }
// }
// }, 100);
if (!this.product) throw new Error("err");
return this.product;
}
// Generic binding for size/version choices, handles UI and fetches updates as needed
bindOption(prodInfoContainer, container, inputName, collection, productProp, labelClass, basketHiddenInput, updatePrice = false) {
prodInfoContainer.addEventListener('click', async (e) => {
const itemEl = e.target.closest('button');
if (!itemEl || !container.contains(itemEl)) return;
container.querySelectorAll('button.active').forEach(btn => btn.classList.remove('active'));
itemEl.classList.add('active');
const itemId = itemEl.dataset.id;
let itemObj = collection.find(item => String(item.id) === String(itemId));
this.product[productProp] = itemObj;
const labelEl = container.closest('.product-info__part').querySelector(`.${labelClass}`);
if (labelEl) labelEl.textContent = itemEl.textContent || itemObj.name;
const hiddenInput = container.closest('.product-info__part')?.querySelector(`input[name="${inputName}"]`);
if (hiddenInput) hiddenInput.value = this.escapeHtml(itemObj.id);
const hiddenBasketInputEl = document.querySelector('.idm-products-banner__add-to-basket-form')
?.querySelector(`input[name="${basketHiddenInput}"]`);
if (hiddenBasketInputEl) hiddenBasketInputEl.value = this.escapeHtml(itemObj.id);
const quickViewPriceEl = container.closest('.product-info__part').querySelector('.idm__quick-view-price');
const quickViewOmnibusEl = container.closest('.product-info__part').querySelector('.price-omnibus');
if (!updatePrice) {
// When switching product version, fetch new product data and re-bind
const idmProductId = Number(itemId);
const newProduct = await this.fetchQuickViewProduct(idmProductId, prodInfoContainer);
this.bindOption(
prodInfoContainer,
prodInfoContainer.querySelector('.idm-versions'),
'currentVersion',
newProduct.group.versions,
'currentVersion',
'version-choose',
'product',
false
);
this.bindOption(
prodInfoContainer,
prodInfoContainer.querySelector('.idm-sizes'),
'currentSize',
newProduct.sizes,
'currentSize',
'size-choose',
'size',
true
);
return;
}
// Fast price info update just for size pick
if (updatePrice && itemObj.price && quickViewPriceEl && quickViewOmnibusEl) {
const crossedPriceVal = itemObj.price.crossedPrice?.gross?.formatted;
const regularPriceVal = itemObj.price.price?.gross?.formatted;
const omnibusPriceVal = itemObj.price.omnibusPrice?.gross?.formatted;
const wrapper = document.querySelector('.idm-products-banner__qty');
this.size = itemObj
this.idmAddToBasket(this.product, this.size)
this.amountToBasket(wrapper)
quickViewPriceEl.innerHTML = `
${regularPriceVal}
${crossedPriceVal ? `<span class="price-crossed">${crossedPriceVal}</span> ` : ""}
<span class="price-note">brutto / szt</span>
`;
quickViewOmnibusEl.innerHTML = omnibusPriceVal
? `Najniższa cena z 30 dni przed obniżką: ${omnibusPriceVal}`
: "";
}
});
}
// Handles add-to-basket GraphQL mutation and in-app UI transitions
addToBasketQuery() {
const addToBasketButton = document.querySelector(
".btn.--solid.--medium.idm-products-banner__add-to-basket-button"
);
const customAdded = document.getElementById('quick_view_basket');
const prodInfoContainer = document.querySelector('.prod-info__container');
const addToBasketForm = document.querySelector(".idm-products-banner__add-to-basket-form");
addToBasketForm.addEventListener("submit", async (el) => {
el.preventDefault();
const qtyElement = document.querySelector('.idm-products-banner__qty-input');
const sellBy = qtyElement.value;
try {
const query = `
mutation {
addProductsToBasket(ProductInput: {
id: ${Number(this.product.id)},
size: "${this.size.id}",
quantity: ${Number(sellBy)}
}) {
status
}
}
`;
const res = await fetch("/graphql/v1/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
if (!res.ok && res.status !== 301 && res.status !== 302) {
throw new Error(`Błąd HTTP: ${res.status}`);
}
const result = await res.json();
const status = result?.data?.addProductsToBasket?.status;
if (status && (status.toLowerCase() === 'success' || status === 'SUCCESS')) {
this.updateBasketAfterAdd();
} else {
throw new Error(`Basket update failed — status = ${status}`);
}
await this.renderCartItemsJS();
} catch (error) {
if (typeof Alertek !== 'undefined') {
Alertek.show_alert("Coś poszło nie tak");
} else {
alert("Coś poszło nie tak");
}
}
// Hide quickview and show confirmation after adding
const rightAsideProdInfo = document.querySelector('.right-aside__prod-info');
const navRightAside = document.querySelector('.right-aside__nav');
navRightAside.style.position = 'absolute';
prodInfoContainer.classList.add("hidden");
customAdded.classList.remove("hidden");
this.basketSubmitted = true;
await this.getCartItems();
return true;
});
return false;
}
updateBasketAfterAdd() {
if (typeof menu_basket_cache === 'function') {
menu_basket_cache(false);
} else {
console.warn("menu_basket_cache is not defined.");
}
}
// Dynamically render add-to-basket UI form and connect handlers
idmAddToBasket(product, size) {
const getElAmmountInCart = app_shop.vars.basket;
const getThisProduct = getElAmmountInCart.products.filter(
(s) => s.id === String(this.product.id) && s.size === String(this.size.id)
);
const sellBy = size?.unitSellby || 1;
const precision = size?.unitPrecision || 0;
let max = (typeof size?.amount === 'number' && size.amount > 0) ? size.amount : '';
const amountInCart = getThisProduct?.[0]?.count ? getThisProduct[0].count : 0;
let inactive = ""
if (amountInCart && amountInCart >= max){
inactive = "inactive";
max = '';
}else if(amountInCart){
max -= amountInCart;
}
console.log("getThisProduct", size.amount, "max", max)
const buttonsToRender = `
<form class='idm-products-banner__add-to-basket-form' method='post' action="/basketchange.php">
<input type='hidden' name='mode' value='1'>
<input type='hidden' name='product' value=${product.id}>
<input type='hidden' name='size' value=${size.id}>
<div class='idm-products-banner__qty'
data-sell-by='${this.escapeHtml(String(sellBy))}'
data-precision='${this.escapeHtml(String(precision))}'
data-max='${this.escapeHtml(String(max))}'>
<button type='button' class='idm-products-banner__qty-decrease'></button>
<input type='number'
name='number'
class='idm-products-banner__qty-input'
value='${this.escapeHtml(String(sellBy))}'
step='${this.escapeHtml(String(sellBy))}'
min='${this.escapeHtml(String(sellBy))}'
max='${this.escapeHtml(String(max))}'>
<button type='button' class='idm-products-banner__qty-increase'>+</button>
</div>
<button type='submit' class='btn --solid --medium idm-products-banner__add-to-basket-button ${inactive}'>
<span>Do koszyka</span>
</button>
</form>`;
// Wait for container before rendering/attaching qty logic
const interval = setInterval(() => {
const container = document.querySelector('.idm-products-banner__add-to-basket');
if (container) {
clearInterval(interval);
container.innerHTML = buttonsToRender;
this.addToBasketQuery();
}
}, 100);
const interval2 = setInterval(() => {
const wrapper = document.querySelector('.idm-products-banner__qty');
if (wrapper) {
clearInterval(interval2);
this.amountToBasket(wrapper, max);
}
}, 100);
return '';
}
// Quantity +/- and validation for add to basket
amountToBasket(wrapper, max){
wrapper.addEventListener('click', async (e) => {
if (!wrapper) return;
const input = wrapper.querySelector('.idm-products-banner__qty-input');
const step = parseFloat(wrapper.dataset.sellBy || '1');
const precision = parseInt(wrapper.dataset.precision || '0');
max = parseFloat(max || '999999');
let current = parseFloat(input.value) || 0;
if (e.target.classList.contains('idm-products-banner__qty-increase')) {
current += step;
if (current > max) current = max;
} else if (e.target.classList.contains('idm-products-banner__qty-decrease')) {
current -= step;
if (current < step) current = step;
}
input.value = current.toFixed(precision);
});
document.addEventListener('blur', (e) => {
if (!e.target.classList.contains('idm-products-banner__qty-input')) return;
const input = e.target;
const wrapper = input.closest('.idm-products-banner__qty');
const step = parseFloat(wrapper.dataset.sellBy || '1');
const precision = parseInt(wrapper.dataset.precision || '0');
const max = parseFloat(wrapper.dataset.max || '999999');
let val = parseFloat(input.value);
if (isNaN(val) || val < step) {
val = step;
} else if (val > max) {
val = max;
} else {
val = Math.round(val / step) * step;
}
input.value = val.toFixed(precision);
}, true);
}
// Returns after basket global variable is updated, for UI sync
getCartItems() {
const cartItems = app_shop.vars.basket;
return new Promise((resolve) => {
const interval = setInterval(() => {
const newCartItems = app_shop.vars.basket;
if (cartItems.productsNumber !== newCartItems.productsNumber) {
clearInterval(interval)
resolve(newCartItems);
}
}, 100);
});
}
// Rebuilds basket preview after add
async renderCartItemsJS() {
const container = document.getElementById('cartItemsContainer');
container.innerHTML = '';
const products = (await this.getCartItems()).products;
products.forEach(product => {
const {
id,
link,
name,
icon,
size_name,
count,
price_unit,
prices
} = product;
const block = document.createElement('div');
block.className = 'added__block';
block.innerHTML = `
<div class="added__product product">
<a class="added__icon product__icon m-0 d-flex justify-content-center align-items-center"
data-product-id="${id}" href="${link}" title="${name}">
<img class="b-lazy" src="${icon}" alt="${name}">
</a>
<div class="added__info">
<h3 class="added__name_h3 d-flex">
<a class="added__name" href="${link}" title="${name}">
${name}
</a>
</h3>
<div class="added__prices">
<div class="added__price_single">
${Number(prices.gros).toFixed(2).replace('.', ',')}
<span class="added__price_sellby">/ ${price_unit}</span>
</div>
<strong class="price">
<span class="price__unit">
${Number(prices.worth_gros).toFixed(2).replace('.', ',')}
</span>
<span class="price_vat">brutto</span>
</strong>
</div>
<ul class="added__params">
<li class="added__params_number">Ilość: ${count}</li>
${size_name && size_name !== 'UNIWERSALNY' ? `
<li class="added__params_size">Rozmiar: ${size_name}</li>
` : ''}
</ul>
</div>
</div>
`;
this.updateShippingProgressBar()
container.appendChild(block);
});
}
// Updates shipping progress bar UI according to basket total
updateShippingProgressBar() {
const basket = app_shop.vars.basket;
if (!basket || !basket.shippingLimitFree) return;
const totalWorth = parseFloat(basket.worth || 0);
const shippingLimit = parseFloat(basket.shippingLimitFree || 0);
const toShippingFree = parseFloat(basket.toShippingFree || 0);
const toShippingFreeFormatted = basket.toShippingFree_formatted || "0,00 zł";
const barEl = document.getElementById("shipping-bar");
const barCompletion = document.getElementById("shipping-bar-completion");
const amountEl = document.getElementById("shipping-left-amount");
const freeEl = document.getElementById("shipping-bar-free");
const notFreeText = document.getElementById("shipping-text-not-free");
if (shippingLimit > 0 && toShippingFree > 0) {
const percentage = Math.min(
100,
((shippingLimit - toShippingFree) / shippingLimit) * 100
);
barCompletion.style.width = percentage + "%";
amountEl.textContent = toShippingFreeFormatted;
barEl.style.display = "block";
freeEl.style.display = "none";
} else {
const shippingfreeBar = document.getElementById('shipping-bar-free').querySelector('.added__shippingfree_bar');
shippingfreeBar.classList.add('enough');
barEl.style.display = "none";
freeEl.style.display = "block";
}
}
}
// Initialize quick view system
const quickView = new ProductQuickView();
quickView.init();