- Docker Compose orchestrating PHP 8.2/Apache web container and MySQL 8.0 - Dockerfile with GD, PDO, MySQLi extensions and security hardening - Apache vhost with mod_rewrite, deflate, expires, and security headers - PHP config with OPcache enabled and display_errors off - MySQL init schema (contacts, coverage_zones tables) - Front-controller PHP router (index.php → pages/) - Responsive homepage: hero, stats bar, services cards, why section, coverage CTA - Stub pages: services, coverage, about, contact (with working form skeleton), 404 - CSS design system using brand palette from logo (navy #0d1b3e → teal #00bcd4 + orange #f57c00 accents) - JS: nav scroll/mobile toggle, IntersectionObserver counter animation, scroll reveal https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
115 lines
3.9 KiB
JavaScript
115 lines
3.9 KiB
JavaScript
/* ============================================================
|
||
ALWISP – main.js
|
||
============================================================ */
|
||
|
||
'use strict';
|
||
|
||
// ── NAV: scroll shadow + mobile toggle ──────────────────────
|
||
(function () {
|
||
const header = document.getElementById('site-header');
|
||
const toggle = document.getElementById('nav-toggle');
|
||
const menu = document.getElementById('nav-menu');
|
||
const navLinks = menu ? menu.querySelectorAll('.nav__link') : [];
|
||
|
||
// Scroll shadow
|
||
if (header) {
|
||
const onScroll = () => header.classList.toggle('scrolled', window.scrollY > 10);
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
onScroll();
|
||
}
|
||
|
||
// Mobile toggle
|
||
if (toggle && menu) {
|
||
toggle.addEventListener('click', () => {
|
||
const isOpen = menu.classList.toggle('is-open');
|
||
toggle.setAttribute('aria-expanded', isOpen);
|
||
});
|
||
|
||
// Close on nav link click
|
||
navLinks.forEach(link => {
|
||
link.addEventListener('click', () => {
|
||
menu.classList.remove('is-open');
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
});
|
||
});
|
||
|
||
// Close on outside click
|
||
document.addEventListener('click', e => {
|
||
if (!header.contains(e.target)) {
|
||
menu.classList.remove('is-open');
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Active nav link
|
||
const currentPath = window.location.pathname.replace(/\/$/, '') || '/';
|
||
navLinks.forEach(link => {
|
||
const href = link.getAttribute('href').replace(/\/$/, '') || '/';
|
||
if (href === currentPath) link.classList.add('nav__link--active');
|
||
});
|
||
}());
|
||
|
||
|
||
// ── COUNTER ANIMATION (stats bar) ───────────────────────────
|
||
(function () {
|
||
const counters = document.querySelectorAll('.stat__value[data-count]');
|
||
if (!counters.length) return;
|
||
|
||
const easeOut = t => 1 - Math.pow(1 - t, 3);
|
||
|
||
function animateCounter(el) {
|
||
const target = parseInt(el.dataset.count, 10);
|
||
const duration = 1400;
|
||
const start = performance.now();
|
||
|
||
function step(now) {
|
||
const elapsed = now - start;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
el.textContent = Math.floor(easeOut(progress) * target);
|
||
if (progress < 1) requestAnimationFrame(step);
|
||
else el.textContent = target;
|
||
}
|
||
requestAnimationFrame(step);
|
||
}
|
||
|
||
const observer = new IntersectionObserver(entries => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
animateCounter(entry.target);
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { threshold: 0.4 });
|
||
|
||
counters.forEach(c => observer.observe(c));
|
||
}());
|
||
|
||
|
||
// ── SCROLL REVEAL (cards, sections) ─────────────────────────
|
||
(function () {
|
||
const revealEls = document.querySelectorAll(
|
||
'.card, .stat, .why__text, .section__header, .cta-band__content'
|
||
);
|
||
|
||
if (!revealEls.length || !('IntersectionObserver' in window)) return;
|
||
|
||
revealEls.forEach((el, i) => {
|
||
el.style.opacity = '0';
|
||
el.style.transform = 'translateY(22px)';
|
||
el.style.transition = `opacity 0.55s ease ${i * 0.07}s, transform 0.55s ease ${i * 0.07}s`;
|
||
});
|
||
|
||
const observer = new IntersectionObserver(entries => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.style.opacity = '1';
|
||
entry.target.style.transform = 'translateY(0)';
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { threshold: 0.12 });
|
||
|
||
revealEls.forEach(el => observer.observe(el));
|
||
}());
|