Track _site/ in repo for direct Caddy deployment

This commit is contained in:
Tom Roth
2026-07-31 13:41:52 +02:00
parent 60e19e8642
commit c0bfd7c77c
71 changed files with 7405 additions and 1 deletions

479
_site/js/main.js Normal file
View File

@@ -0,0 +1,479 @@
/**
* main.js — HER Home Enhancement and Renovation
*
* Sections:
* 1. Navigation — mobile hamburger (focus trap, Escape, ARIA)
* 2. Navigation — compact + CTA visibility on scroll
* 3. Scroll-triggered fade-up animation (IntersectionObserver)
* 4. Quote form — validation, character counter, submission
*/
(function () {
'use strict';
/* ============================================================
1. Navigation — Mobile Hamburger
============================================================ */
var navToggle = document.querySelector('.nav__toggle');
var navOverlay = document.querySelector('.nav__overlay');
var navOverlayClose = document.querySelector('.nav__overlay-close');
/**
* Returns an array of focusable elements within a container,
* in DOM order, skipping anything with tabindex="-1".
*/
function getFocusable(container) {
return Array.from(
container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
)
);
}
function openNav() {
if (!navOverlay || !navToggle) return;
navOverlay.removeAttribute('hidden');
navToggle.setAttribute('aria-expanded', 'true');
navToggle.setAttribute('aria-label', 'Close menu');
document.body.classList.add('nav-open');
// Move focus into overlay — first focusable item
var focusable = getFocusable(navOverlay);
if (focusable.length) focusable[0].focus();
}
function closeNav() {
if (!navOverlay || !navToggle) return;
navOverlay.setAttribute('hidden', '');
navToggle.setAttribute('aria-expanded', 'false');
navToggle.setAttribute('aria-label', 'Open menu');
document.body.classList.remove('nav-open');
navToggle.focus();
}
if (navToggle) {
navToggle.addEventListener('click', function () {
if (navToggle.getAttribute('aria-expanded') === 'true') {
closeNav();
} else {
openNav();
}
});
}
if (navOverlayClose) {
navOverlayClose.addEventListener('click', closeNav);
}
if (navOverlay) {
// Close when a nav link inside the overlay is clicked
navOverlay.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'A') {
closeNav();
}
});
}
// Keyboard handling: focus trap (Tab/Shift-Tab) and Escape close
document.addEventListener('keydown', function (e) {
if (!navOverlay || navOverlay.hasAttribute('hidden')) return;
if (e.key === 'Escape') {
closeNav();
return;
}
if (e.key === 'Tab') {
var focusable = getFocusable(navOverlay);
if (!focusable.length) {
e.preventDefault();
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift-Tab on first → wrap to last
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab on last → wrap to first
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
});
/* ============================================================
2. Navigation — Compact on Scroll
============================================================ */
var navEl = document.querySelector('.nav');
// #nav-cta starts with style="display:none" in HTML; we manage via style.display
var navCta = document.getElementById('nav-cta');
function handleNavScroll() {
var y = window.scrollY;
if (navEl) {
if (y > 80) {
navEl.classList.add('nav--compact');
} else {
navEl.classList.remove('nav--compact');
}
}
if (navCta) {
// Remove the inline display:none to show; restore it to hide
navCta.style.display = y > 400 ? '' : 'none';
}
}
window.addEventListener('scroll', handleNavScroll, { passive: true });
// Apply immediately so state is correct before any scroll occurs
handleNavScroll();
/* ============================================================
3. Scroll-triggered Fade-Up Animation
============================================================ */
(function initFadeUp() {
// Respect user's motion preference — CSS already handles the fallback
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
var fadeEls = document.querySelectorAll('.fade-up');
if (!fadeEls.length) return;
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // one-shot — never toggle back
}
});
}, { threshold: 0.15 });
fadeEls.forEach(function (el) {
observer.observe(el);
});
}());
/* ============================================================
4. Quote Form
============================================================ */
(function initQuoteForm() {
/** POST destination — replace with your API Gateway invoke URL */
var FORM_ENDPOINT = 'REPLACE_WITH_API_GATEWAY_URL';
var form = document.getElementById('quote-form');
if (!form) return; // form not present on this page — bail out
// ── Element references ────────────────────────────────────
var honeypot = form.querySelector('input[name="website"]');
var errorBanner = form.querySelector('.form-error-banner');
var successPanel = form.querySelector('.form-success');
var submitBtn = form.querySelector('.form__submit');
var charCountEl = document.getElementById('description-char-count');
var descriptionEl = document.getElementById('description');
var FIELD_IDS = ['name', 'address', 'phone', 'email', 'description'];
// Save original submit button label so we can restore it after an error
var originalBtnText = submitBtn ? (submitBtn.textContent.trim() || 'Send Request') : 'Send Request';
// Ensure the error banner is focusable (needed for focus management)
if (errorBanner && !errorBanner.hasAttribute('tabindex')) {
errorBanner.setAttribute('tabindex', '-1');
}
// ── Validation rules ──────────────────────────────────────
var RULES = {
name: function (val) {
return val.trim().length >= 2
? null
: 'Please enter your name.';
},
address: function (val) {
return val.trim().length >= 3
? null
: 'Please enter the service address or city.';
},
phone: function (val) {
return /^[+\d][\d\s\-().]{6,}$/.test(val.trim())
? null
: 'Please enter a valid phone number.';
},
email: function (val) {
var v = val.trim();
return (v.indexOf('@') !== -1 && v.indexOf('.') !== -1)
? null
: 'Please enter a valid email address.';
},
description: function (val) {
return val.trim().length >= 10
? null
: 'Please describe your project (at least 10 characters).';
}
};
// ── Helper: show / clear individual field errors ──────────
/**
* Mark a field as invalid and display its error message.
* @param {string} fieldId - The field's id attribute value
* @param {string} message - The error text to display
*/
function showError(fieldId, message) {
var field = document.getElementById(fieldId);
var errorEl = document.getElementById(fieldId + '-error');
if (field) field.setAttribute('aria-invalid', 'true');
if (errorEl) {
errorEl.textContent = message;
errorEl.removeAttribute('hidden');
}
}
/**
* Mark a field as valid and hide its error message.
* @param {string} fieldId - The field's id attribute value
*/
function clearError(fieldId) {
var field = document.getElementById(fieldId);
var errorEl = document.getElementById(fieldId + '-error');
if (field) field.setAttribute('aria-invalid', 'false');
if (errorEl) {
errorEl.textContent = '';
errorEl.setAttribute('hidden', '');
}
}
// ── Helper: validate one field ────────────────────────────
/**
* Run validation for a single field. Shows or clears its error.
* @param {string} fieldId
* @returns {boolean} true if valid
*/
function validateField(fieldId) {
var field = document.getElementById(fieldId);
if (!field) return true; // element absent — treat as valid
var rule = RULES[fieldId];
if (!rule) return true;
var errorMsg = rule(field.value);
if (errorMsg) {
showError(fieldId, errorMsg);
return false;
}
clearError(fieldId);
return true;
}
// ── Helper: validate all fields ───────────────────────────
/**
* Run validation on every form field. Does NOT short-circuit —
* all fields are checked so every error is visible at once.
* @returns {boolean} true only if every field is valid
*/
function validateAll() {
var allValid = true;
FIELD_IDS.forEach(function (id) {
if (!validateField(id)) allValid = false;
});
return allValid;
}
// ── Blur-time validation ──────────────────────────────────
FIELD_IDS.forEach(function (id) {
var field = document.getElementById(id);
if (field) {
field.addEventListener('blur', function () {
validateField(id);
});
}
});
// ── Character counter ─────────────────────────────────────
if (descriptionEl && charCountEl) {
// Set initial display
charCountEl.textContent = '0 / 2000';
descriptionEl.addEventListener('input', function () {
var len = descriptionEl.value.length;
charCountEl.textContent = len + ' / 2000';
// classList.toggle(cls, force) is supported in all modern browsers
charCountEl.classList.toggle('warn', len > 1800);
});
}
// ── Banner helpers ────────────────────────────────────────
/**
* Display the error banner with a given message and move focus to it.
* @param {string} message
*/
function showBannerError(message) {
if (!errorBanner) return;
// Update the inner <p> if present to preserve markup; else use textContent
var bannerP = errorBanner.querySelector('p');
if (bannerP) {
bannerP.textContent = message;
} else {
errorBanner.textContent = message;
}
errorBanner.removeAttribute('hidden');
errorBanner.focus();
}
function hideBanner() {
if (!errorBanner) return;
errorBanner.setAttribute('hidden', '');
var bannerP = errorBanner.querySelector('p');
if (bannerP) {
bannerP.textContent = '';
} else {
errorBanner.textContent = '';
}
}
// ── Success display ───────────────────────────────────────
/**
* Hide all form content except the success panel, then show the
* success panel and move focus to its title.
* (.form-success lives inside <form> in the HTML, so we cannot
* simply hide the <form> element — instead we hide its siblings.)
*/
function showSuccess() {
// Hide every direct child of the form except .form-success
Array.from(form.children).forEach(function (child) {
if (!child.classList.contains('form-success')) {
child.setAttribute('hidden', '');
}
});
if (successPanel) {
successPanel.removeAttribute('hidden');
var title = successPanel.querySelector('.form-success__title');
if (title) {
// Ensure the heading is programmatically focusable
if (!title.hasAttribute('tabindex')) {
title.setAttribute('tabindex', '-1');
}
title.focus();
}
}
}
// ── Form state helpers ────────────────────────────────────
var allInputs; // captured at submit time, used in onError
function disableForm() {
allInputs = form.querySelectorAll('input, textarea, select, button');
allInputs.forEach(function (el) { el.disabled = true; });
if (submitBtn) submitBtn.textContent = 'Sending\u2026'; // "Sending…"
form.setAttribute('aria-busy', 'true');
}
function enableForm() {
if (allInputs) {
allInputs.forEach(function (el) { el.disabled = false; });
}
if (submitBtn) submitBtn.textContent = originalBtnText;
form.removeAttribute('aria-busy');
}
// ── Submit handler ────────────────────────────────────────
form.addEventListener('submit', function (e) {
e.preventDefault();
// Reset error banner from any previous attempt
hideBanner();
// 1. Validate — show all errors simultaneously
var isValid = validateAll();
if (!isValid) {
showBannerError('Please correct the errors above before submitting.');
return;
}
// 2. Honeypot check — silently fake success so bots see no difference
if (honeypot && honeypot.value) {
showSuccess();
return;
}
// 3. Lock the form while the request is in flight
disableForm();
// 4. Build the JSON payload
var payload = {
name: (document.getElementById('name') || { value: '' }).value,
address: (document.getElementById('address') || { value: '' }).value,
phone: (document.getElementById('phone') || { value: '' }).value,
email: (document.getElementById('email') || { value: '' }).value,
description: (document.getElementById('description') || { value: '' }).value,
honeypot: honeypot ? honeypot.value : ''
};
// 5. 10-second hard timeout via AbortController
var controller = new AbortController();
var abortTimer = setTimeout(function () {
controller.abort();
}, 10000);
// 6. POST to the API
fetch(FORM_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal
})
.then(function (response) {
// Treat any non-2xx HTTP status as an error
if (!response.ok) {
throw new Error('HTTP error ' + response.status);
}
return response.json();
})
.then(function (data) {
clearTimeout(abortTimer);
// Some API Gateway patterns return { ok: false } with HTTP 200
if (data && data.ok === false) {
throw new Error(data.message || 'Server returned an error.');
}
// 7. Success path
form.removeAttribute('aria-busy'); // clear before showSuccess hides children
showSuccess();
})
.catch(function () {
clearTimeout(abortTimer);
// 8. Error path — re-enable form and surface the error
enableForm();
showBannerError(
'Something went wrong sending your request. ' +
'Please try again, or contact Diana directly.'
);
});
});
}()); // end initQuoteForm
}()); // end top-level IIFE