623 lines
18 KiB
JavaScript
623 lines
18 KiB
JavaScript
import './bootstrap';
|
|
|
|
const SCROLL_KEY = 'pc-preserve-scroll-y';
|
|
const ALERT_TIMEOUT = 5000;
|
|
const NAV_HIDE_OFFSET = 96;
|
|
const NAV_SCROLL_DELTA = 8;
|
|
const CHAT_OPEN_KEY = 'pc-chat-widget-open';
|
|
|
|
const header = document.querySelector('.pc-header');
|
|
const mobileMenuToggle = document.getElementById('pc-mobile-menu-toggle');
|
|
let lastScrollY = window.scrollY;
|
|
let isHeaderTicking = false;
|
|
|
|
const updateHeaderNavVisibility = () => {
|
|
if (!header) {
|
|
return;
|
|
}
|
|
|
|
const currentScrollY = window.scrollY;
|
|
const scrollDelta = currentScrollY - lastScrollY;
|
|
const isMobileMenuOpen = mobileMenuToggle instanceof HTMLInputElement && mobileMenuToggle.checked;
|
|
|
|
if (currentScrollY <= NAV_HIDE_OFFSET || scrollDelta < -NAV_SCROLL_DELTA || isMobileMenuOpen) {
|
|
header.classList.remove('is-nav-hidden');
|
|
} else if (scrollDelta > NAV_SCROLL_DELTA) {
|
|
header.classList.add('is-nav-hidden');
|
|
}
|
|
|
|
lastScrollY = currentScrollY;
|
|
};
|
|
|
|
const initChatWidget = () => {
|
|
const widget = document.querySelector('[data-chat-widget="true"]');
|
|
if (!(widget instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const toggle = widget.querySelector('[data-chat-toggle]');
|
|
const close = widget.querySelector('[data-chat-close]');
|
|
const panel = widget.querySelector('[data-chat-panel]');
|
|
const messages = widget.querySelector('[data-chat-messages]');
|
|
const form = widget.querySelector('[data-chat-form]');
|
|
const note = widget.querySelector('[data-chat-note]');
|
|
|
|
if (
|
|
!(toggle instanceof HTMLButtonElement) ||
|
|
!(panel instanceof HTMLElement) ||
|
|
!(messages instanceof HTMLElement) ||
|
|
!(form instanceof HTMLFormElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const fetchUrl = widget.dataset.fetchUrl;
|
|
const sendUrl = widget.dataset.sendUrl;
|
|
const csrf = widget.dataset.csrf;
|
|
const textarea = form.querySelector('textarea[name="message"]');
|
|
const submitButton = form.querySelector('button[type="submit"]');
|
|
|
|
if (
|
|
typeof fetchUrl !== 'string' ||
|
|
fetchUrl === '' ||
|
|
typeof sendUrl !== 'string' ||
|
|
sendUrl === '' ||
|
|
typeof csrf !== 'string' ||
|
|
csrf === '' ||
|
|
!(textarea instanceof HTMLTextAreaElement) ||
|
|
!(submitButton instanceof HTMLButtonElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let pollTimer = null;
|
|
let isFetching = false;
|
|
let conversationClosed = false;
|
|
const defaultNoteText = note instanceof HTMLElement ? note.textContent ?? '' : '';
|
|
|
|
const applyConversationState = (conversation) => {
|
|
conversationClosed = Boolean(conversation && conversation.is_closed);
|
|
|
|
if (note instanceof HTMLElement) {
|
|
const closedNotice = typeof conversation?.notice === 'string' ? conversation.notice : '';
|
|
note.textContent = conversationClosed && closedNotice !== '' ? closedNotice : defaultNoteText;
|
|
}
|
|
|
|
submitButton.textContent = conversationClosed ? 'Начать новый чат' : 'Отправить';
|
|
|
|
if (conversationClosed) {
|
|
stopPolling();
|
|
}
|
|
};
|
|
|
|
const renderMessages = (items) => {
|
|
if (!Array.isArray(items)) {
|
|
return;
|
|
}
|
|
|
|
messages.innerHTML = '';
|
|
items.forEach((message) => {
|
|
const row = document.createElement('div');
|
|
row.className = `pc-chat-message ${message.sender === 'admin' ? 'is-admin' : 'is-customer'}`;
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'pc-chat-bubble';
|
|
bubble.textContent = typeof message.body === 'string' ? message.body : '';
|
|
|
|
const meta = document.createElement('span');
|
|
meta.className = 'pc-chat-meta';
|
|
meta.textContent = typeof message.time === 'string' ? message.time : '';
|
|
|
|
row.appendChild(bubble);
|
|
row.appendChild(meta);
|
|
messages.appendChild(row);
|
|
});
|
|
|
|
messages.scrollTop = messages.scrollHeight;
|
|
};
|
|
|
|
const fetchMessages = async () => {
|
|
if (isFetching) {
|
|
return;
|
|
}
|
|
|
|
isFetching = true;
|
|
try {
|
|
const response = await fetch(fetchUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
renderMessages(payload.messages ?? []);
|
|
applyConversationState(payload.conversation ?? null);
|
|
} catch (error) {
|
|
// Network errors are expected during reconnects; do nothing.
|
|
} finally {
|
|
isFetching = false;
|
|
}
|
|
};
|
|
|
|
const startPolling = () => {
|
|
if (pollTimer !== null || conversationClosed) {
|
|
return;
|
|
}
|
|
|
|
pollTimer = window.setInterval(fetchMessages, 4000);
|
|
};
|
|
|
|
const stopPolling = () => {
|
|
if (pollTimer === null) {
|
|
return;
|
|
}
|
|
|
|
window.clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
};
|
|
|
|
const openPanel = async () => {
|
|
panel.hidden = false;
|
|
widget.classList.add('is-open');
|
|
window.localStorage.setItem(CHAT_OPEN_KEY, '1');
|
|
await fetchMessages();
|
|
startPolling();
|
|
window.setTimeout(() => textarea.focus(), 80);
|
|
};
|
|
|
|
const closePanel = () => {
|
|
panel.hidden = true;
|
|
widget.classList.remove('is-open');
|
|
window.localStorage.removeItem(CHAT_OPEN_KEY);
|
|
stopPolling();
|
|
};
|
|
|
|
toggle.addEventListener('click', () => {
|
|
if (widget.classList.contains('is-open')) {
|
|
closePanel();
|
|
} else {
|
|
void openPanel();
|
|
}
|
|
});
|
|
|
|
if (close instanceof HTMLButtonElement) {
|
|
close.addEventListener('click', closePanel);
|
|
}
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const body = textarea.value.trim();
|
|
if (body === '') {
|
|
return;
|
|
}
|
|
|
|
submitButton.disabled = true;
|
|
textarea.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch(sendUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrf,
|
|
},
|
|
body: JSON.stringify({ message: body }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
textarea.value = '';
|
|
await fetchMessages();
|
|
startPolling();
|
|
} catch (error) {
|
|
// Skip noisy errors and keep the form usable.
|
|
} finally {
|
|
submitButton.disabled = false;
|
|
textarea.disabled = false;
|
|
textarea.focus();
|
|
}
|
|
});
|
|
|
|
if (window.localStorage.getItem(CHAT_OPEN_KEY) === '1') {
|
|
void openPanel();
|
|
}
|
|
};
|
|
|
|
const initHomeSliders = () => {
|
|
const sliders = document.querySelectorAll('[data-home-slider]');
|
|
if (sliders.length === 0) {
|
|
return;
|
|
}
|
|
|
|
sliders.forEach((slider) => {
|
|
const slides = Array.from(slider.querySelectorAll('[data-home-slide]'));
|
|
const prevButton = slider.querySelector('[data-home-slider-prev]');
|
|
const nextButton = slider.querySelector('[data-home-slider-next]');
|
|
|
|
if (
|
|
slides.length === 0 ||
|
|
!(prevButton instanceof HTMLButtonElement) ||
|
|
!(nextButton instanceof HTMLButtonElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let index = Math.max(
|
|
0,
|
|
slides.findIndex((slide) => slide.classList.contains('is-active')),
|
|
);
|
|
let timerId = null;
|
|
|
|
const setSlide = (newIndex) => {
|
|
index = (newIndex + slides.length) % slides.length;
|
|
slides.forEach((slide, slideIndex) => {
|
|
slide.classList.toggle('is-active', slideIndex === index);
|
|
});
|
|
};
|
|
|
|
const runNext = () => setSlide(index + 1);
|
|
const runPrev = () => setSlide(index - 1);
|
|
|
|
const startAuto = () => {
|
|
if (slides.length <= 1 || timerId !== null) {
|
|
return;
|
|
}
|
|
|
|
timerId = window.setInterval(runNext, 5000);
|
|
};
|
|
|
|
const stopAuto = () => {
|
|
if (timerId === null) {
|
|
return;
|
|
}
|
|
|
|
window.clearInterval(timerId);
|
|
timerId = null;
|
|
};
|
|
|
|
prevButton.addEventListener('click', () => {
|
|
runPrev();
|
|
stopAuto();
|
|
startAuto();
|
|
});
|
|
|
|
nextButton.addEventListener('click', () => {
|
|
runNext();
|
|
stopAuto();
|
|
startAuto();
|
|
});
|
|
|
|
slider.addEventListener('mouseenter', stopAuto);
|
|
slider.addEventListener('mouseleave', startAuto);
|
|
|
|
setSlide(index);
|
|
startAuto();
|
|
});
|
|
};
|
|
|
|
const initProductCarousels = () => {
|
|
const carousels = document.querySelectorAll('[data-product-carousel]');
|
|
if (carousels.length === 0) {
|
|
return;
|
|
}
|
|
|
|
carousels.forEach((carousel) => {
|
|
const track = carousel.querySelector('[data-product-carousel-track]');
|
|
const prevButton = carousel.querySelector('[data-product-carousel-prev]');
|
|
const nextButton = carousel.querySelector('[data-product-carousel-next]');
|
|
|
|
if (
|
|
!(track instanceof HTMLElement) ||
|
|
!(prevButton instanceof HTMLButtonElement) ||
|
|
!(nextButton instanceof HTMLButtonElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const updateButtons = () => {
|
|
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
|
|
if (maxScroll <= 1) {
|
|
prevButton.disabled = true;
|
|
nextButton.disabled = true;
|
|
return;
|
|
}
|
|
|
|
prevButton.disabled = track.scrollLeft <= 2;
|
|
nextButton.disabled = track.scrollLeft >= maxScroll - 2;
|
|
};
|
|
|
|
const scrollByItem = (direction) => {
|
|
const firstItem = track.querySelector('.pc-product-carousel-item');
|
|
const styles = window.getComputedStyle(track);
|
|
const gapValue = styles.columnGap || styles.gap || '0px';
|
|
const gap = Number.parseFloat(gapValue) || 0;
|
|
const itemWidth =
|
|
firstItem instanceof HTMLElement
|
|
? firstItem.getBoundingClientRect().width
|
|
: Math.max(track.clientWidth * 0.9, 220);
|
|
const scrollAmount = itemWidth + gap;
|
|
|
|
track.scrollBy({
|
|
left: scrollAmount * direction,
|
|
behavior: 'smooth',
|
|
});
|
|
};
|
|
|
|
prevButton.addEventListener('click', () => {
|
|
scrollByItem(-1);
|
|
});
|
|
|
|
nextButton.addEventListener('click', () => {
|
|
scrollByItem(1);
|
|
});
|
|
|
|
track.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
window.requestAnimationFrame(updateButtons);
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
window.addEventListener('resize', updateButtons);
|
|
updateButtons();
|
|
});
|
|
};
|
|
|
|
const initProductGallery = () => {
|
|
const galleries = document.querySelectorAll('[data-product-gallery]');
|
|
if (galleries.length === 0) {
|
|
return;
|
|
}
|
|
|
|
galleries.forEach((gallery) => {
|
|
const mainImage = gallery.querySelector('[data-product-gallery-main]');
|
|
if (!(mainImage instanceof HTMLImageElement)) {
|
|
return;
|
|
}
|
|
|
|
const thumbs = Array.from(gallery.querySelectorAll('[data-product-gallery-thumb]')).filter(
|
|
(thumb) => thumb instanceof HTMLButtonElement,
|
|
);
|
|
|
|
if (thumbs.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let activeIndex = 0;
|
|
let touchStartX = null;
|
|
let touchStartY = null;
|
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
const setActiveThumb = (activeThumb) => {
|
|
thumbs.forEach((thumb) => {
|
|
const isActive = thumb === activeThumb;
|
|
thumb.classList.toggle('is-active', isActive);
|
|
thumb.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
});
|
|
};
|
|
|
|
const animateMainImage = (direction) => {
|
|
if (prefersReducedMotion || typeof mainImage.animate !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const offset = direction === 0 ? 0 : 28 * direction;
|
|
mainImage.getAnimations().forEach((animation) => animation.cancel());
|
|
mainImage.animate([
|
|
{
|
|
opacity: 0.38,
|
|
transform: `translate3d(${offset}px, 0, 0) scale(1.02)`,
|
|
filter: 'blur(10px)',
|
|
},
|
|
{
|
|
opacity: 1,
|
|
transform: 'translate3d(0, 0, 0) scale(1)',
|
|
filter: 'blur(0)',
|
|
},
|
|
], {
|
|
duration: 320,
|
|
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
});
|
|
};
|
|
|
|
const applyImage = (index) => {
|
|
const normalizedIndex = ((index % thumbs.length) + thumbs.length) % thumbs.length;
|
|
const thumb = thumbs[normalizedIndex];
|
|
if (!(thumb instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const src = (thumb.dataset.imageSrc || '').trim();
|
|
if (src === '') {
|
|
return;
|
|
}
|
|
|
|
const direction = normalizedIndex === activeIndex
|
|
? 0
|
|
: normalizedIndex > activeIndex
|
|
? 1
|
|
: -1;
|
|
|
|
activeIndex = normalizedIndex;
|
|
|
|
if (mainImage.getAttribute('src') !== src) {
|
|
mainImage.setAttribute('src', src);
|
|
}
|
|
|
|
const alt = (thumb.dataset.imageAlt || '').trim();
|
|
if (alt !== '') {
|
|
mainImage.setAttribute('alt', alt);
|
|
}
|
|
|
|
animateMainImage(direction);
|
|
setActiveThumb(thumb);
|
|
thumb.scrollIntoView({
|
|
behavior: 'smooth',
|
|
inline: 'center',
|
|
block: 'nearest',
|
|
});
|
|
};
|
|
|
|
thumbs.forEach((thumb, index) => {
|
|
thumb.addEventListener('click', () => {
|
|
applyImage(index);
|
|
});
|
|
});
|
|
|
|
mainImage.addEventListener('touchstart', (event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch) {
|
|
return;
|
|
}
|
|
|
|
touchStartX = touch.clientX;
|
|
touchStartY = touch.clientY;
|
|
}, { passive: true });
|
|
|
|
mainImage.addEventListener('touchend', (event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch || touchStartX === null || touchStartY === null) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = touch.clientX - touchStartX;
|
|
const deltaY = touch.clientY - touchStartY;
|
|
|
|
touchStartX = null;
|
|
touchStartY = null;
|
|
|
|
if (Math.abs(deltaX) < 36 || Math.abs(deltaX) <= Math.abs(deltaY)) {
|
|
return;
|
|
}
|
|
|
|
if (deltaX < 0) {
|
|
applyImage(activeIndex + 1);
|
|
} else {
|
|
applyImage(activeIndex - 1);
|
|
}
|
|
}, { passive: true });
|
|
|
|
const initialThumb = thumbs.find((thumb) => thumb.classList.contains('is-active')) ?? thumbs[0];
|
|
if (initialThumb) {
|
|
applyImage(thumbs.indexOf(initialThumb));
|
|
}
|
|
});
|
|
};
|
|
|
|
const initCategoryFilterToggle = () => {
|
|
const toggles = document.querySelectorAll('[data-filter-toggle]');
|
|
if (toggles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
toggles.forEach((toggle) => {
|
|
if (!(toggle instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const targetId = toggle.getAttribute('aria-controls');
|
|
if (!targetId) {
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById(targetId);
|
|
if (!(container instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const syncState = () => {
|
|
const isOpen = container instanceof HTMLDetailsElement
|
|
? container.open
|
|
: container.classList.contains('is-open');
|
|
|
|
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
};
|
|
|
|
toggle.addEventListener('click', () => {
|
|
if (container instanceof HTMLDetailsElement) {
|
|
container.open = !container.open;
|
|
} else {
|
|
container.classList.toggle('is-open');
|
|
}
|
|
|
|
syncState();
|
|
});
|
|
|
|
if (container instanceof HTMLDetailsElement) {
|
|
container.addEventListener('toggle', syncState);
|
|
}
|
|
|
|
syncState();
|
|
});
|
|
};
|
|
|
|
document.addEventListener('submit', (event) => {
|
|
const form = event.target;
|
|
if (!(form instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
|
|
if (form.dataset.preserveScroll === 'true') {
|
|
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
|
|
}
|
|
});
|
|
|
|
window.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
if (!header || isHeaderTicking) {
|
|
return;
|
|
}
|
|
|
|
isHeaderTicking = true;
|
|
window.requestAnimationFrame(() => {
|
|
updateHeaderNavVisibility();
|
|
isHeaderTicking = false;
|
|
});
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
if (mobileMenuToggle instanceof HTMLInputElement) {
|
|
mobileMenuToggle.addEventListener('change', () => {
|
|
if (mobileMenuToggle.checked) {
|
|
header?.classList.remove('is-nav-hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener('pageshow', () => {
|
|
const savedScrollY = sessionStorage.getItem(SCROLL_KEY);
|
|
if (savedScrollY !== null) {
|
|
sessionStorage.removeItem(SCROLL_KEY);
|
|
window.requestAnimationFrame(() => {
|
|
window.scrollTo(0, Number(savedScrollY) || 0);
|
|
updateHeaderNavVisibility();
|
|
});
|
|
} else {
|
|
updateHeaderNavVisibility();
|
|
}
|
|
|
|
document.querySelectorAll('.pc-alert').forEach((alert) => {
|
|
window.setTimeout(() => {
|
|
alert.classList.add('is-hiding');
|
|
window.setTimeout(() => alert.remove(), 260);
|
|
}, ALERT_TIMEOUT);
|
|
});
|
|
});
|
|
|
|
initChatWidget();
|
|
initHomeSliders();
|
|
initProductCarousels();
|
|
initProductGallery();
|
|
initCategoryFilterToggle();
|