// ---------- 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); } }); }