Initial commit
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
ssww23
2026-03-10 00:55:37 +03:00
parent fc0f28d830
commit 93a655235a
155 changed files with 24768 additions and 0 deletions

622
resources/js/app.js Normal file
View 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();