261 lines
6.9 KiB
JavaScript
261 lines
6.9 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
setupMobileNavigation();
|
|
setupScrollAnimations();
|
|
setupContactForm();
|
|
setupSmoothScrollClose();
|
|
});
|
|
|
|
function setupMobileNavigation() {
|
|
const toggle = document.querySelector("[data-menu-toggle]");
|
|
const mobileNav = document.querySelector("[data-mobile-nav]");
|
|
|
|
if (!toggle || !mobileNav) {
|
|
return;
|
|
}
|
|
|
|
const updateState = (isOpen) => {
|
|
toggle.classList.toggle("is-open", isOpen);
|
|
toggle.setAttribute("aria-expanded", String(isOpen));
|
|
mobileNav.classList.toggle("is-open", isOpen);
|
|
document.body.classList.toggle("menu-open", isOpen);
|
|
};
|
|
|
|
toggle.addEventListener("click", () => {
|
|
const isOpen = toggle.getAttribute("aria-expanded") === "true";
|
|
updateState(!isOpen);
|
|
});
|
|
|
|
mobileNav.querySelectorAll("a").forEach((link) => {
|
|
link.addEventListener("click", () => updateState(false));
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
updateState(false);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const clickedInsideMenu = mobileNav.contains(event.target);
|
|
const clickedToggle = toggle.contains(event.target);
|
|
|
|
if (!clickedInsideMenu && !clickedToggle) {
|
|
updateState(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupSmoothScrollClose() {
|
|
const samePageLinks = document.querySelectorAll('a[href^="#"]');
|
|
|
|
samePageLinks.forEach((link) => {
|
|
link.addEventListener("click", (event) => {
|
|
const targetId = link.getAttribute("href");
|
|
|
|
if (!targetId || targetId === "#") {
|
|
return;
|
|
}
|
|
|
|
const target = document.querySelector(targetId);
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const stickyHeader = document.querySelector(".site-header");
|
|
const headerOffset = stickyHeader ? stickyHeader.offsetHeight + 16 : 16;
|
|
const targetTop = target.getBoundingClientRect().top + window.scrollY - headerOffset;
|
|
|
|
window.scrollTo({
|
|
top: Math.max(targetTop, 0),
|
|
behavior: "smooth"
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupScrollAnimations() {
|
|
const animatedItems = document.querySelectorAll("[data-animate]");
|
|
|
|
if (!animatedItems.length) {
|
|
return;
|
|
}
|
|
|
|
if (!("IntersectionObserver" in window)) {
|
|
animatedItems.forEach((item) => item.classList.add("is-visible"));
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries, currentObserver) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) {
|
|
return;
|
|
}
|
|
|
|
entry.target.classList.add("is-visible");
|
|
currentObserver.unobserve(entry.target);
|
|
});
|
|
},
|
|
{
|
|
threshold: 0.12,
|
|
rootMargin: "0px 0px -40px 0px"
|
|
}
|
|
);
|
|
|
|
animatedItems.forEach((item) => observer.observe(item));
|
|
}
|
|
|
|
function setupContactForm() {
|
|
const form = document.querySelector("[data-contact-form]");
|
|
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
const fields = Array.from(form.querySelectorAll("[data-required], [type='email']"));
|
|
const status = form.querySelector("[data-form-status]");
|
|
const submitButton = form.querySelector("button[type='submit']");
|
|
const action = (form.getAttribute("action") || "").trim();
|
|
const hasExternalEndpoint = /^https?:\/\//i.test(action);
|
|
const hasPlaceholderEndpoint = action.includes("YOUR_ID_HERE");
|
|
const method = (form.getAttribute("method") || "post").toUpperCase();
|
|
const buttonText = submitButton ? submitButton.textContent : "";
|
|
|
|
const validators = {
|
|
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
};
|
|
|
|
const setStatus = (message, state = "") => {
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
status.textContent = message;
|
|
status.classList.toggle("is-success", state === "success");
|
|
status.classList.toggle("is-error", state === "error");
|
|
};
|
|
|
|
const setSubmitting = (isSubmitting) => {
|
|
if (!submitButton) {
|
|
return;
|
|
}
|
|
|
|
submitButton.disabled = isSubmitting;
|
|
submitButton.setAttribute("aria-busy", String(isSubmitting));
|
|
submitButton.textContent = isSubmitting ? "Sending..." : buttonText;
|
|
};
|
|
|
|
const setFieldError = (field, message) => {
|
|
const wrapper = field.closest(".form-field");
|
|
const error = wrapper ? wrapper.querySelector(".form-field__error") : null;
|
|
|
|
field.setAttribute("aria-invalid", String(Boolean(message)));
|
|
|
|
if (wrapper) {
|
|
wrapper.classList.toggle("is-invalid", Boolean(message));
|
|
}
|
|
|
|
if (error) {
|
|
error.textContent = message || "";
|
|
}
|
|
};
|
|
|
|
const validateField = (field) => {
|
|
const rawValue = field.value.trim();
|
|
const label = field.dataset.label || "This field";
|
|
|
|
if (field.hasAttribute("data-required") && !rawValue) {
|
|
setFieldError(field, `${label} is required.`);
|
|
return false;
|
|
}
|
|
|
|
if (field.type === "email" && rawValue && !validators.email(rawValue)) {
|
|
setFieldError(field, "Enter a valid email address.");
|
|
return false;
|
|
}
|
|
|
|
setFieldError(field, "");
|
|
return true;
|
|
};
|
|
|
|
fields.forEach((field) => {
|
|
field.addEventListener("blur", () => validateField(field));
|
|
field.addEventListener("input", () => {
|
|
if (field.getAttribute("aria-invalid") === "true") {
|
|
validateField(field);
|
|
}
|
|
});
|
|
});
|
|
|
|
form.addEventListener("submit", (event) => {
|
|
let isValid = true;
|
|
|
|
fields.forEach((field) => {
|
|
if (!validateField(field)) {
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
if (!isValid) {
|
|
event.preventDefault();
|
|
setStatus("Please review the highlighted fields and try again.", "error");
|
|
|
|
const firstInvalid = form.querySelector("[aria-invalid='true']");
|
|
|
|
if (firstInvalid) {
|
|
firstInvalid.focus();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!hasExternalEndpoint || hasPlaceholderEndpoint) {
|
|
event.preventDefault();
|
|
setStatus("Contact form is currently unavailable. Please try again shortly.", "error");
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
setSubmitting(true);
|
|
setStatus("Sending your message...");
|
|
|
|
fetch(action, {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json"
|
|
},
|
|
body: new FormData(form)
|
|
})
|
|
.then(async (response) => {
|
|
if (response.ok) {
|
|
form.reset();
|
|
fields.forEach((field) => setFieldError(field, ""));
|
|
setStatus("Thank you. Your contact request was sent and noted.", "success");
|
|
return;
|
|
}
|
|
|
|
let errorMessage = "Sorry, your message could not be sent. Please try again shortly.";
|
|
|
|
try {
|
|
const payload = await response.json();
|
|
|
|
if (payload && Array.isArray(payload.errors) && payload.errors.length > 0) {
|
|
errorMessage = payload.errors.map((item) => item.message).join(" ");
|
|
}
|
|
} catch (parseError) {
|
|
// Keep fallback message when response body is not JSON.
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
})
|
|
.catch((error) => {
|
|
setStatus(error.message, "error");
|
|
})
|
|
.finally(() => {
|
|
setSubmitting(false);
|
|
});
|
|
});
|
|
}
|