update
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/* ============================================================
|
||||
components.js — Component loader + header/footer init
|
||||
Loads shared HTML components, initializes sticky header,
|
||||
mobile nav, and active link state.
|
||||
============================================================ */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* --- Component Loader ----------------------------------- */
|
||||
async function loadComponent(targetId, url) {
|
||||
const el = document.getElementById(targetId);
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const html = await res.text();
|
||||
el.innerHTML = html;
|
||||
} catch (err) {
|
||||
console.warn('[components.js] Could not load', url, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Active nav link ------------------------------------ */
|
||||
function markActiveNav() {
|
||||
const path = window.location.pathname.replace(/\/$/, '') || '/';
|
||||
document.querySelectorAll('.header-nav a, .mobile-nav-links a').forEach(a => {
|
||||
const href = a.getAttribute('href').replace(/\/$/, '') || '/';
|
||||
if (path === href || (href !== '/' && path.startsWith(href))) {
|
||||
a.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Sticky header -------------------------------------- */
|
||||
function initStickyHeader() {
|
||||
const header = document.querySelector('.site-header');
|
||||
if (!header) return;
|
||||
|
||||
const toggle = () => {
|
||||
header.classList.toggle('scrolled', window.scrollY > 60);
|
||||
};
|
||||
toggle();
|
||||
window.addEventListener('scroll', toggle, { passive: true });
|
||||
}
|
||||
|
||||
/* --- Mobile nav ---------------------------------------- */
|
||||
function initMobileNav() {
|
||||
const btn = document.querySelector('.header-menu-btn');
|
||||
const nav = document.querySelector('.mobile-nav');
|
||||
const overlay = document.querySelector('.mobile-nav-overlay');
|
||||
const close = document.querySelector('.mobile-nav-close');
|
||||
if (!btn || !nav) return;
|
||||
|
||||
const open = () => { nav.classList.add('open'); btn.classList.add('open'); document.body.style.overflow = 'hidden'; };
|
||||
const shut = () => { nav.classList.remove('open'); btn.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
|
||||
btn.addEventListener('click', open);
|
||||
if (overlay) overlay.addEventListener('click', shut);
|
||||
if (close) close.addEventListener('click', shut);
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') shut(); });
|
||||
}
|
||||
|
||||
/* --- Init on DOM ready ---------------------------------- */
|
||||
async function init() {
|
||||
const base = document.querySelector('meta[name="site-root"]')?.content || '/';
|
||||
|
||||
await Promise.all([
|
||||
loadComponent('site-header', base + 'components/header.html'),
|
||||
loadComponent('site-footer', base + 'components/footer.html'),
|
||||
]);
|
||||
|
||||
initStickyHeader();
|
||||
initMobileNav();
|
||||
markActiveNav();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,207 @@
|
||||
/* ============================================================
|
||||
form.js — Estimate form validation + submission
|
||||
Real-time validation, phone formatting, reCAPTCHA v3 hook
|
||||
============================================================ */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const PHONE = /^\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$/;
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const RECAPTCHA_SITE_KEY = '6LdqrB8rAAAAAOrBCYmtk43IzemkiK_Fb2EYU5q2';
|
||||
|
||||
/* --- Helpers -------------------------------------------- */
|
||||
function field(el) {
|
||||
return el.closest('.form-field');
|
||||
}
|
||||
|
||||
function setValid(el) {
|
||||
const f = field(el);
|
||||
if (!f) return;
|
||||
f.classList.remove('has-error');
|
||||
el.classList.add('valid');
|
||||
el.classList.remove('invalid');
|
||||
}
|
||||
|
||||
function setInvalid(el, msg) {
|
||||
const f = field(el);
|
||||
if (!f) return;
|
||||
f.classList.add('has-error');
|
||||
el.classList.add('invalid');
|
||||
el.classList.remove('valid');
|
||||
const errEl = f.querySelector('.err-msg');
|
||||
if (errEl && msg) errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function clearState(el) {
|
||||
const f = field(el);
|
||||
if (!f) return;
|
||||
f.classList.remove('has-error');
|
||||
el.classList.remove('valid', 'invalid');
|
||||
}
|
||||
|
||||
/* --- Phone formatter ------------------------------------ */
|
||||
function formatPhone(raw) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 10);
|
||||
if (digits.length < 4) return digits;
|
||||
if (digits.length < 7) return '(' + digits.slice(0,3) + ') ' + digits.slice(3);
|
||||
return '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6);
|
||||
}
|
||||
|
||||
/* --- Validators ----------------------------------------- */
|
||||
function validateRequired(el) {
|
||||
if (!el.value.trim()) {
|
||||
setInvalid(el, 'This field is required.');
|
||||
return false;
|
||||
}
|
||||
setValid(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateEmail(el) {
|
||||
if (!el.value.trim()) {
|
||||
setInvalid(el, 'Email address is required.');
|
||||
return false;
|
||||
}
|
||||
if (!EMAIL.test(el.value.trim())) {
|
||||
setInvalid(el, 'Please enter a valid email address.');
|
||||
return false;
|
||||
}
|
||||
setValid(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validatePhone(el) {
|
||||
const val = el.value.replace(/\D/g, '');
|
||||
if (!val) {
|
||||
setInvalid(el, 'Phone number is required.');
|
||||
return false;
|
||||
}
|
||||
if (val.length !== 10) {
|
||||
setInvalid(el, 'Please enter a 10-digit phone number.');
|
||||
return false;
|
||||
}
|
||||
setValid(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* --- reCAPTCHA v3 token --------------------------------- */
|
||||
function getRecaptchaToken(action) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof grecaptcha === 'undefined') {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
grecaptcha.ready(() => {
|
||||
grecaptcha.execute(RECAPTCHA_SITE_KEY, { action }).then(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Form handler --------------------------------------- */
|
||||
function initForm(form) {
|
||||
const nameEl = form.querySelector('#name');
|
||||
const emailEl = form.querySelector('#email');
|
||||
const phoneEl = form.querySelector('#phone');
|
||||
const addrEl = form.querySelector('#address');
|
||||
const serviceEl = form.querySelector('#service');
|
||||
const msgEl = form.querySelector('#message');
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
const status = form.querySelector('.form-status');
|
||||
|
||||
if (!submit) return;
|
||||
|
||||
/* Phone real-time format */
|
||||
if (phoneEl) {
|
||||
phoneEl.addEventListener('input', () => {
|
||||
const pos = phoneEl.selectionStart;
|
||||
const prev = phoneEl.value;
|
||||
phoneEl.value = formatPhone(prev);
|
||||
/* restore cursor roughly */
|
||||
const diff = phoneEl.value.length - prev.length;
|
||||
try { phoneEl.setSelectionRange(pos + diff, pos + diff); } catch (_) {}
|
||||
});
|
||||
|
||||
phoneEl.addEventListener('blur', () => validatePhone(phoneEl));
|
||||
}
|
||||
|
||||
/* Blur-time validation for other fields */
|
||||
if (nameEl) nameEl.addEventListener('blur', () => validateRequired(nameEl));
|
||||
if (emailEl) emailEl.addEventListener('blur', () => validateEmail(emailEl));
|
||||
if (addrEl) addrEl.addEventListener('blur', () => validateRequired(addrEl));
|
||||
if (serviceEl) serviceEl.addEventListener('change', () => validateRequired(serviceEl));
|
||||
|
||||
/* Submit */
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const checks = [
|
||||
nameEl ? validateRequired(nameEl) : true,
|
||||
emailEl ? validateEmail(emailEl) : true,
|
||||
phoneEl ? validatePhone(phoneEl) : true,
|
||||
addrEl ? validateRequired(addrEl) : true,
|
||||
serviceEl ? validateRequired(serviceEl) : true,
|
||||
];
|
||||
|
||||
if (checks.includes(false)) {
|
||||
const firstErr = form.querySelector('.invalid');
|
||||
if (firstErr) firstErr.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const origText = submit.textContent;
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Sending...';
|
||||
if (status) { status.className = 'form-status'; status.textContent = ''; }
|
||||
|
||||
const token = await getRecaptchaToken('estimate_form');
|
||||
|
||||
const payload = {
|
||||
name: nameEl ? nameEl.value.trim() : '',
|
||||
email: emailEl ? emailEl.value.trim() : '',
|
||||
phone: phoneEl ? phoneEl.value.trim() : '',
|
||||
address: addrEl ? addrEl.value.trim() : '',
|
||||
service: serviceEl ? serviceEl.value : '',
|
||||
message: msgEl ? msgEl.value.trim() : '',
|
||||
token,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/estimate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!status) { submit.disabled = false; submit.textContent = origText; return; }
|
||||
|
||||
if (res.ok) {
|
||||
status.className = 'form-status form-status--success';
|
||||
status.textContent = 'Thank you! We will get back to you within 1 business hour.';
|
||||
form.reset();
|
||||
form.querySelectorAll('input, textarea, select').forEach(clearState);
|
||||
} else {
|
||||
throw new Error(res.status);
|
||||
}
|
||||
} catch (_) {
|
||||
if (status) {
|
||||
status.className = 'form-status form-status--error';
|
||||
status.textContent = 'Something went wrong. Please call us directly at (716) 602-1429.';
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = origText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
document.querySelectorAll('.estimate-form').forEach(initForm);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,164 @@
|
||||
/* ============================================================
|
||||
main.js — Scroll animations, counters, FAQ, BA slider
|
||||
============================================================ */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* --- Scroll animation (IntersectionObserver) ------------ */
|
||||
function initScrollAnimations() {
|
||||
const els = document.querySelectorAll('[data-animate]');
|
||||
if (!els.length) return;
|
||||
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('in-view');
|
||||
obs.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });
|
||||
|
||||
els.forEach(el => obs.observe(el));
|
||||
}
|
||||
|
||||
/* --- Animated counters ---------------------------------- */
|
||||
function animateCounter(el) {
|
||||
const target = parseFloat(el.dataset.count);
|
||||
const suffix = el.dataset.suffix || '';
|
||||
const prefix = el.dataset.prefix || '';
|
||||
const decimals = (target % 1 !== 0) ? 1 : 0;
|
||||
const duration = 1600;
|
||||
const start = performance.now();
|
||||
|
||||
function tick(now) {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
const value = target * ease;
|
||||
el.textContent = prefix + value.toFixed(decimals) + suffix;
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function initCounters() {
|
||||
const counters = document.querySelectorAll('[data-count]');
|
||||
if (!counters.length) return;
|
||||
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
obs.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
counters.forEach(el => obs.observe(el));
|
||||
}
|
||||
|
||||
/* --- FAQ accordion -------------------------------------- */
|
||||
function initFAQ() {
|
||||
document.querySelectorAll('.faq-item').forEach(item => {
|
||||
const q = item.querySelector('.faq-question');
|
||||
if (!q) return;
|
||||
|
||||
q.addEventListener('click', () => {
|
||||
const isOpen = item.classList.contains('open');
|
||||
// close all
|
||||
document.querySelectorAll('.faq-item.open').forEach(i => {
|
||||
i.classList.remove('open');
|
||||
});
|
||||
if (!isOpen) item.classList.add('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Before / After slider ------------------------------ */
|
||||
function initBASlider(slider) {
|
||||
const handle = slider.querySelector('.ba-handle');
|
||||
const beforeWrap = slider.querySelector('.ba-before-wrap');
|
||||
if (!handle || !beforeWrap) return;
|
||||
|
||||
let dragging = false;
|
||||
|
||||
function setPosition(clientX) {
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const rawPct = (clientX - rect.left) / rect.width;
|
||||
const pct = Math.min(Math.max(rawPct, 0.02), 0.98);
|
||||
beforeWrap.style.width = (pct * 100) + '%';
|
||||
handle.style.left = (pct * 100) + '%';
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', () => { dragging = true; });
|
||||
handle.addEventListener('touchstart', () => { dragging = true; }, { passive: true });
|
||||
|
||||
window.addEventListener('mousemove', e => { if (dragging) setPosition(e.clientX); });
|
||||
window.addEventListener('touchmove', e => { if (dragging) setPosition(e.touches[0].clientX); }, { passive: true });
|
||||
|
||||
window.addEventListener('mouseup', () => { dragging = false; });
|
||||
window.addEventListener('touchend', () => { dragging = false; });
|
||||
|
||||
slider.addEventListener('click', e => setPosition(e.clientX));
|
||||
}
|
||||
|
||||
function initBASliders() {
|
||||
document.querySelectorAll('.ba-slider').forEach(initBASlider);
|
||||
}
|
||||
|
||||
/* --- Smooth scroll for anchor links --------------------- */
|
||||
function initSmoothScroll() {
|
||||
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
const id = a.getAttribute('href').slice(1);
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
const headerH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 72;
|
||||
const top = target.getBoundingClientRect().top + window.scrollY - headerH - 16;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Video hero fallback -------------------------------- */
|
||||
function initHeroVideo() {
|
||||
const video = document.querySelector('.hero-video-wrap video');
|
||||
if (!video) return;
|
||||
video.play().catch(() => {
|
||||
// autoplay blocked — poster image is visible; nothing to do
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Testimonial auto-scroll (on mobile) ---------------- */
|
||||
function initTestimonialScroll() {
|
||||
const track = document.querySelector('.testimonial-track');
|
||||
if (!track) return;
|
||||
|
||||
let isPaused = false;
|
||||
track.addEventListener('mouseenter', () => { isPaused = true; });
|
||||
track.addEventListener('mouseleave', () => { isPaused = false; });
|
||||
|
||||
// keyboard scroll within track
|
||||
track.setAttribute('tabindex', '0');
|
||||
}
|
||||
|
||||
/* --- Boot ---------------------------------------------- */
|
||||
function boot() {
|
||||
initScrollAnimations();
|
||||
initCounters();
|
||||
initFAQ();
|
||||
initBASliders();
|
||||
initSmoothScroll();
|
||||
initHeroVideo();
|
||||
initTestimonialScroll();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user