428th-website-2026/preview/common.js
Artemii Peretiachenko 674a4b50da Add a separate preview of the black redesign so it can be reviewed without changing the live site.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 20:17:14 +02:00

470 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);
}
});
});
}
// ---------- NAV MAGNET (как ghost / EclipseButton) ----------
function initNavMagnet(){
const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (prefersReducedMotion || !canHover) return;
document.querySelectorAll('.main-nav a:not(.nav-cta)').forEach(link => {
let raf = 0;
let tx = 0;
let ty = 0;
let ts = 1;
let cx = 0;
let cy = 0;
let cs = 1;
function tick(){
cx += (tx - cx) * 0.18;
cy += (ty - cy) * 0.18;
cs += (ts - cs) * 0.18;
link.style.transform = `translate3d(${cx}px, ${cy}px, 0) scale(${cs})`;
const done = Math.abs(tx - cx) + Math.abs(ty - cy) + Math.abs(ts - cs) < 0.04;
raf = done ? 0 : requestAnimationFrame(tick);
}
function schedule(){
if (!raf) raf = requestAnimationFrame(tick);
}
link.addEventListener('mousemove', (e) => {
const rect = link.getBoundingClientRect();
tx = (e.clientX - rect.left - rect.width / 2) * 0.15;
ty = (e.clientY - rect.top - rect.height / 2) * 0.15;
ts = 1.02;
schedule();
});
link.addEventListener('mouseleave', () => {
tx = 0;
ty = 0;
ts = 1;
schedule();
});
});
}
// ---------- 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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error('request_failed');
status.textContent = form.dataset.statusSuccess || '✓';
form.reset();
setTimeout(() => modal.classList.remove('open'), 1200);
} catch (err) {
status.textContent = form.dataset.statusError || 'Error';
}
});
}