467 lines
16 KiB
JavaScript
467 lines
16 KiB
JavaScript
// ---------- SHARED UTILITIES ----------
|
||
function readCssPx(name, fallback, el = document.documentElement){
|
||
const value = getComputedStyle(el).getPropertyValue(name).trim();
|
||
const parsed = parseFloat(value);
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
// ---------- ЯЗЫК (переключатель = ссылки на статические URL) ----------
|
||
function initLangSwitch(){
|
||
const root = document.getElementById('langSwitch');
|
||
if (!root) return;
|
||
|
||
root.querySelectorAll('a[data-set-lang]').forEach(link => {
|
||
link.addEventListener('click', () => {
|
||
const lang = link.getAttribute('data-set-lang');
|
||
if (lang === 'ru' || lang === 'en' || lang === 'ua') {
|
||
localStorage.setItem('lang', lang);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- ТЕМА ----------
|
||
function getSystemTheme(){
|
||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
|
||
function resolveTheme(){
|
||
const saved = localStorage.getItem('theme');
|
||
if (saved === 'light' || saved === 'dark') return saved;
|
||
return getSystemTheme();
|
||
}
|
||
|
||
function applyTheme(theme){
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
}
|
||
|
||
function initTheme(){
|
||
const themeToggle = document.getElementById('themeToggle');
|
||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||
|
||
applyTheme(resolveTheme());
|
||
|
||
themeToggle.addEventListener('click', () => {
|
||
const current = document.documentElement.getAttribute('data-theme');
|
||
const next = current === 'light' ? 'dark' : 'light';
|
||
applyTheme(next);
|
||
localStorage.setItem('theme', next);
|
||
});
|
||
|
||
const onSystemChange = () => {
|
||
if (localStorage.getItem('theme')) return;
|
||
applyTheme(getSystemTheme());
|
||
};
|
||
if (typeof media.addEventListener === 'function') {
|
||
media.addEventListener('change', onSystemChange);
|
||
} else if (typeof media.addListener === 'function') {
|
||
media.addListener(onSystemChange);
|
||
}
|
||
}
|
||
|
||
// ---------- HERO LOGO SCROLL ANIMATION ----------
|
||
function initHeroScroll(config){
|
||
const {
|
||
section,
|
||
stage,
|
||
brand,
|
||
brandInner,
|
||
mobileAnimClass,
|
||
scrollDrivenClass,
|
||
brandScaleAnimation,
|
||
syncFit,
|
||
brandGapFromSection = false,
|
||
clampMobileScale = null,
|
||
clearSectionVars = null,
|
||
setMobileScaleVar = false,
|
||
subtractRunwayOnMobile = true
|
||
} = config;
|
||
|
||
const siteHeader = document.querySelector('.site-header');
|
||
const mainNav = document.querySelector('.main-nav');
|
||
|
||
let cachedBaseHeight = 0;
|
||
let cachedBrandGap = 32;
|
||
let cachedMaxScale = 1.9;
|
||
let cachedMaxBrandHeight = 0;
|
||
let cachedMinBrandHeight = 0;
|
||
let cachedNavHeight = 0;
|
||
let heroRunway = 0;
|
||
let heroStartY = 0;
|
||
let heroResizeTimer = 0;
|
||
let heroIsMobile = false;
|
||
let heroUseScrollTimeline = false;
|
||
let lastHeroScrollStep = -1;
|
||
let postHeroLift = null;
|
||
|
||
const heroRoot = document.documentElement;
|
||
|
||
function clearHeroAnimationVars(){
|
||
heroRoot.style.removeProperty('--hero-scale');
|
||
heroRoot.style.removeProperty('--hero-brand-height');
|
||
heroRoot.style.removeProperty('--hero-stage-height');
|
||
heroRoot.style.removeProperty('--hero-brand-max-h');
|
||
heroRoot.style.removeProperty('--hero-brand-min-h');
|
||
heroRoot.style.removeProperty('--hero-nav-shift');
|
||
heroRoot.style.removeProperty('--hero-nav-shift-max');
|
||
heroRoot.style.removeProperty('--hero-scroll-start');
|
||
heroRoot.style.removeProperty('--hero-scroll-end');
|
||
if (brandInner){
|
||
brandInner.style.removeProperty('animation-range');
|
||
brandInner.style.removeProperty('animation-timeline');
|
||
brandInner.style.removeProperty('animation');
|
||
}
|
||
if (mainNav){
|
||
mainNav.style.removeProperty('animation-range');
|
||
mainNav.style.removeProperty('animation-timeline');
|
||
mainNav.style.removeProperty('animation');
|
||
mainNav.style.removeProperty('transform');
|
||
}
|
||
if (postHeroLift){
|
||
postHeroLift.style.removeProperty('animation-range');
|
||
postHeroLift.style.removeProperty('animation-timeline');
|
||
postHeroLift.style.removeProperty('animation');
|
||
postHeroLift.style.removeProperty('transform');
|
||
delete postHeroLift.dataset.heroLiftExited;
|
||
}
|
||
if (clearSectionVars) clearSectionVars(section);
|
||
}
|
||
|
||
function ensurePostHeroLift(){
|
||
if (postHeroLift?.isConnected) return postHeroLift;
|
||
postHeroLift = section?.parentElement?.querySelector(':scope > .post-hero-lift') || null;
|
||
return postHeroLift;
|
||
}
|
||
|
||
function applyHeroScrollAnimations(range){
|
||
brandInner.style.animation = `${brandScaleAnimation} ease-out both`;
|
||
brandInner.style.animationTimeline = 'scroll(root block)';
|
||
brandInner.style.animationRange = range;
|
||
mainNav.style.animation = 'hero-nav-lift ease-out both';
|
||
mainNav.style.animationTimeline = 'scroll(root block)';
|
||
mainNav.style.animationRange = range;
|
||
ensurePostHeroLift();
|
||
if (postHeroLift){
|
||
postHeroLift.style.animation = 'hero-content-lift ease-out both';
|
||
postHeroLift.style.animationTimeline = 'scroll(root block)';
|
||
postHeroLift.style.animationRange = range;
|
||
postHeroLift.style.removeProperty('transform');
|
||
delete postHeroLift.dataset.heroLiftExited;
|
||
}
|
||
}
|
||
|
||
function setHeroMobileMode(enabled){
|
||
if (!section) return;
|
||
section.classList.toggle(mobileAnimClass, enabled);
|
||
section.classList.toggle(scrollDrivenClass, enabled && heroUseScrollTimeline);
|
||
if (enabled) ensurePostHeroLift();
|
||
}
|
||
|
||
function getHeroScrollRunwayMultiplier(){
|
||
return readCssPx('--hero-scroll-runway-multiplier', 1);
|
||
}
|
||
|
||
function getHeroEffectiveRunway(){
|
||
return heroRunway * getHeroScrollRunwayMultiplier();
|
||
}
|
||
|
||
function getHeroAnimationRange(){
|
||
return `${heroStartY}px ${heroStartY + getHeroEffectiveRunway()}px`;
|
||
}
|
||
|
||
function easeOutCubic(t){
|
||
return 1 - (1 - t) ** 3;
|
||
}
|
||
|
||
function getHeroScrollProgress(){
|
||
return Math.min(Math.max(window.scrollY - heroStartY, 0), getHeroEffectiveRunway());
|
||
}
|
||
|
||
function updateHeroContentLift(){
|
||
if (!heroIsMobile || prefersReducedMotion || !section){
|
||
if (postHeroLift) postHeroLift.style.removeProperty('transform');
|
||
return;
|
||
}
|
||
|
||
ensurePostHeroLift();
|
||
if (!postHeroLift) return;
|
||
|
||
if (heroUseScrollTimeline){
|
||
const exited = section.getBoundingClientRect().bottom <= 0;
|
||
if (exited){
|
||
if (postHeroLift.dataset.heroLiftExited !== '1'){
|
||
postHeroLift.style.animation = 'none';
|
||
postHeroLift.style.transform = 'translate3d(0, 0, 0)';
|
||
postHeroLift.dataset.heroLiftExited = '1';
|
||
}
|
||
return;
|
||
}
|
||
if (postHeroLift.dataset.heroLiftExited === '1'){
|
||
delete postHeroLift.dataset.heroLiftExited;
|
||
const range = getHeroAnimationRange();
|
||
postHeroLift.style.animation = 'hero-content-lift ease-out both';
|
||
postHeroLift.style.animationTimeline = 'scroll(root block)';
|
||
postHeroLift.style.animationRange = range;
|
||
postHeroLift.style.removeProperty('transform');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (section.getBoundingClientRect().bottom <= 0){
|
||
postHeroLift.style.transform = 'translate3d(0, 0, 0)';
|
||
return;
|
||
}
|
||
const navShift = getComputedStyle(heroRoot).getPropertyValue('--hero-nav-shift').trim() || '0px';
|
||
postHeroLift.style.transform = `translate3d(0, ${navShift}, 0)`;
|
||
}
|
||
|
||
function isHeroMobileViewport(){
|
||
return window.innerWidth < 600;
|
||
}
|
||
|
||
function getHeroMaxScale(){
|
||
const mobile = window.innerWidth < 600;
|
||
const scope = section || document.documentElement;
|
||
return readCssPx(mobile ? '--hero-max-scale-mobile' : '--hero-max-scale', mobile ? 1.6 : 2.2, scope);
|
||
}
|
||
|
||
function syncSiteHeaderHeight(){
|
||
if (!siteHeader) return;
|
||
const height = siteHeader.getBoundingClientRect().height;
|
||
document.documentElement.style.setProperty('--site-header-height', `${Math.ceil(height)}px`);
|
||
}
|
||
|
||
function getHeroBrandHeight(scale){
|
||
return cachedBaseHeight * scale + cachedBrandGap;
|
||
}
|
||
|
||
function getHeroStickyTop(){
|
||
return readCssPx('--site-header-height', 57) + readCssPx('--hero-stage-offset', 80);
|
||
}
|
||
|
||
function measureHero(){
|
||
syncSiteHeaderHeight();
|
||
heroIsMobile = isHeroMobileViewport();
|
||
lastHeroScrollStep = -1;
|
||
|
||
if (!brandInner || !brand || !section || !stage || prefersReducedMotion){
|
||
heroUseScrollTimeline = false;
|
||
setHeroMobileMode(false);
|
||
clearHeroAnimationVars();
|
||
syncFit();
|
||
return;
|
||
}
|
||
|
||
const gapScope = brandGapFromSection ? section : document.documentElement;
|
||
cachedBrandGap = readCssPx('--hero-brand-gap', 32, gapScope);
|
||
cachedMaxScale = getHeroMaxScale();
|
||
clearHeroAnimationVars();
|
||
brandInner.style.removeProperty('transform');
|
||
|
||
syncFit();
|
||
cachedBaseHeight = brandInner.offsetHeight;
|
||
if (heroIsMobile){
|
||
if (clampMobileScale){
|
||
cachedMaxScale = clampMobileScale(cachedMaxScale, brand, brandInner, section);
|
||
}
|
||
cachedMaxBrandHeight = getHeroBrandHeight(cachedMaxScale);
|
||
cachedMinBrandHeight = getHeroBrandHeight(1);
|
||
} else {
|
||
cachedMaxBrandHeight = getHeroBrandHeight(1);
|
||
cachedMinBrandHeight = getHeroBrandHeight(1 / cachedMaxScale);
|
||
}
|
||
cachedNavHeight = mainNav ? mainNav.offsetHeight : 0;
|
||
heroRunway = Math.max(cachedMaxBrandHeight - cachedMinBrandHeight, 0);
|
||
heroStartY = stage.getBoundingClientRect().top + window.scrollY - getHeroStickyTop();
|
||
|
||
heroUseScrollTimeline = heroIsMobile
|
||
&& typeof CSS !== 'undefined'
|
||
&& CSS.supports('animation-timeline', 'scroll()');
|
||
|
||
if (heroIsMobile){
|
||
setHeroMobileMode(true);
|
||
if (setMobileScaleVar){
|
||
section.style.setProperty('--hero-max-scale-mobile', cachedMaxScale.toFixed(4));
|
||
}
|
||
heroRoot.style.setProperty('--hero-brand-max-h', `${cachedMaxBrandHeight}px`);
|
||
heroRoot.style.setProperty('--hero-brand-min-h', `${cachedMinBrandHeight}px`);
|
||
heroRoot.style.setProperty('--hero-brand-height', `${cachedMaxBrandHeight}px`);
|
||
heroRoot.style.setProperty('--hero-stage-height', `${cachedMaxBrandHeight + cachedNavHeight}px`);
|
||
heroRoot.style.setProperty('--hero-nav-shift-max', `${-(cachedMaxBrandHeight - cachedMinBrandHeight)}px`);
|
||
if (heroUseScrollTimeline){
|
||
applyHeroScrollAnimations(getHeroAnimationRange());
|
||
}
|
||
} else {
|
||
setHeroMobileMode(false);
|
||
}
|
||
|
||
updateHeroBrand();
|
||
updateHeroContentLift();
|
||
}
|
||
|
||
function updateHeroBrand(){
|
||
if (!brandInner || !brand || !stage || prefersReducedMotion || cachedBaseHeight <= 0) return;
|
||
if (heroUseScrollTimeline) return;
|
||
|
||
const scrolled = getHeroScrollProgress();
|
||
const effectiveRunway = getHeroEffectiveRunway();
|
||
const linearProgress = effectiveRunway > 0 ? scrolled / effectiveRunway : 1;
|
||
const progress = easeOutCubic(Math.min(linearProgress, 1));
|
||
|
||
if (heroIsMobile){
|
||
const scale = cachedMaxScale - progress * (cachedMaxScale - 1);
|
||
const brandHeight = cachedBaseHeight * scale + cachedBrandGap;
|
||
const navShift = cachedMaxBrandHeight - brandHeight;
|
||
|
||
heroRoot.style.setProperty('--hero-scale', scale.toFixed(4));
|
||
heroRoot.style.setProperty('--hero-nav-shift', `${-navShift}px`);
|
||
updateHeroContentLift();
|
||
return;
|
||
}
|
||
|
||
if (scrolled === lastHeroScrollStep) return;
|
||
lastHeroScrollStep = scrolled;
|
||
|
||
const brandHeight = cachedMaxBrandHeight - progress * (cachedMaxBrandHeight - cachedMinBrandHeight);
|
||
const scale = (brandHeight - cachedBrandGap) / cachedBaseHeight;
|
||
|
||
heroRoot.style.setProperty('--hero-scale', scale.toFixed(4));
|
||
heroRoot.style.setProperty('--hero-brand-height', `${brandHeight}px`);
|
||
heroRoot.style.setProperty('--hero-stage-height', `${brandHeight + cachedNavHeight}px`);
|
||
}
|
||
|
||
let heroScrollRaf = 0;
|
||
let heroLastScrollY = -1;
|
||
|
||
function runHeroScrollFrame(){
|
||
heroScrollRaf = 0;
|
||
const scrollY = window.scrollY;
|
||
|
||
if (heroIsMobile) updateHeroContentLift();
|
||
if (!heroUseScrollTimeline) updateHeroBrand();
|
||
|
||
if (scrollY !== heroLastScrollY){
|
||
heroLastScrollY = scrollY;
|
||
scheduleHeroScrollFrame();
|
||
}
|
||
}
|
||
|
||
function scheduleHeroScrollFrame(){
|
||
if (heroScrollRaf) return;
|
||
heroScrollRaf = requestAnimationFrame(runHeroScrollFrame);
|
||
}
|
||
|
||
function onHeroScroll(){
|
||
scheduleHeroScrollFrame();
|
||
}
|
||
|
||
function initHeroAnimation(){
|
||
measureHero();
|
||
}
|
||
|
||
function scheduleHeroMeasure(){
|
||
clearTimeout(heroResizeTimer);
|
||
heroResizeTimer = setTimeout(initHeroAnimation, 120);
|
||
}
|
||
|
||
function getNavScrollOffset(){
|
||
return readCssPx('--site-header-height', 57) + 24;
|
||
}
|
||
|
||
function getSectionScrollInset(target){
|
||
const paddingTop = parseFloat(getComputedStyle(target).paddingTop);
|
||
return Number.isFinite(paddingTop) ? paddingTop : 0;
|
||
}
|
||
|
||
function scrollToSection(target){
|
||
const offset = getNavScrollOffset();
|
||
let top = target.getBoundingClientRect().top + window.scrollY - offset + getSectionScrollInset(target);
|
||
const belowHero = section
|
||
&& (section.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||
const canSubtractRunway = subtractRunwayOnMobile || !heroIsMobile;
|
||
if (belowHero && heroRunway > 0 && canSubtractRunway && window.scrollY < heroStartY + heroRunway){
|
||
top -= heroRunway;
|
||
}
|
||
|
||
window.scrollTo({
|
||
top: Math.max(top, 0),
|
||
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
||
});
|
||
}
|
||
|
||
window.addEventListener('scroll', onHeroScroll, { passive: true });
|
||
window.addEventListener('resize', scheduleHeroMeasure);
|
||
window.addEventListener('load', initHeroAnimation);
|
||
|
||
if (brandInner && window.ResizeObserver){
|
||
const heroResizeObserver = new ResizeObserver(scheduleHeroMeasure);
|
||
heroResizeObserver.observe(brandInner);
|
||
}
|
||
|
||
if (siteHeader && window.ResizeObserver){
|
||
const headerResizeObserver = new ResizeObserver(scheduleHeroMeasure);
|
||
headerResizeObserver.observe(siteHeader);
|
||
}
|
||
|
||
initHeroAnimation();
|
||
|
||
document.querySelectorAll('.main-nav a[href^="#"]').forEach(link => {
|
||
link.addEventListener('click', e => {
|
||
const target = document.querySelector(link.getAttribute('href'));
|
||
if (!target) return;
|
||
|
||
e.preventDefault();
|
||
scrollToSection(target);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- REVEAL ON SCROLL ----------
|
||
function initReveal(){
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting){
|
||
entry.target.classList.add('visible');
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { threshold: 0.15 });
|
||
|
||
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
|
||
}
|
||
|
||
// ---------- МОДАЛКА + ФОРМА ----------
|
||
function initContactForm(){
|
||
const modal = document.getElementById('contactModal');
|
||
document.getElementById('openContactForm').addEventListener('click', () => modal.classList.add('open'));
|
||
document.getElementById('closeContactForm').addEventListener('click', () => modal.classList.remove('open'));
|
||
modal.addEventListener('click', e => { if (e.target === modal) modal.classList.remove('open'); });
|
||
|
||
const form = document.getElementById('contactForm');
|
||
const status = document.getElementById('formStatus');
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
status.textContent = form.dataset.statusLoading || '...';
|
||
|
||
const data = Object.fromEntries(new FormData(form).entries());
|
||
|
||
try {
|
||
const res = await fetch('/api/contact', { // placeholder — заменит программист
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
if (!res.ok) throw new Error();
|
||
status.textContent = form.dataset.statusSuccess || '✓';
|
||
form.reset();
|
||
setTimeout(() => modal.classList.remove('open'), 1200);
|
||
} catch (err) {
|
||
status.textContent = form.dataset.statusSuccess || '✓'; // без реального сервера считаем успехом
|
||
form.reset();
|
||
setTimeout(() => modal.classList.remove('open'), 1200);
|
||
}
|
||
});
|
||
}
|