This commit is contained in:
622
resources/js/app.js
Normal file
622
resources/js/app.js
Normal file
@@ -0,0 +1,622 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user