Add app.js

Quick View logic is here
This commit is contained in:
2025-10-02 14:47:19 +02:00
commit b9d6985ec7

622
app.js Normal file
View File

@@ -0,0 +1,622 @@
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();