550 lines
18 KiB
JavaScript
550 lines
18 KiB
JavaScript
(() => {
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const MOBILE_RE = /^\d{10,15}$/;
|
||
|
||
function resolveApiBase() {
|
||
if (window.EVENTBUZZ_API_BASE) {
|
||
return String(window.EVENTBUZZ_API_BASE).replace(/\/$/, "");
|
||
}
|
||
const host = window.location.hostname;
|
||
if (host === "localhost" || host === "127.0.0.1") {
|
||
return "http://localhost:3001/api";
|
||
}
|
||
return "https://api.eventbuzz.live/api";
|
||
}
|
||
|
||
const API_BASE = resolveApiBase();
|
||
|
||
/* ---------- Mobile nav drawer ---------- */
|
||
function initNav() {
|
||
const nav = document.querySelector("nav");
|
||
const menuBtn = document.querySelector(".nav-menu-btn");
|
||
if (!nav || !menuBtn) return;
|
||
|
||
const mq = window.matchMedia("(max-width: 900px)");
|
||
let drawerOpen = false;
|
||
let drawerEls = null;
|
||
|
||
const links = Array.from(
|
||
document.querySelectorAll(".navlinks-desktop a")
|
||
).map((a) => ({
|
||
href: a.getAttribute("href") || "#",
|
||
label: a.textContent.trim(),
|
||
target: a.getAttribute("target") || null,
|
||
}));
|
||
|
||
function syncDrawerMode() {
|
||
nav.classList.toggle("is-drawer-mode", mq.matches);
|
||
if (!mq.matches) closeDrawer(true);
|
||
}
|
||
|
||
function ensureDrawer() {
|
||
if (drawerEls) return drawerEls;
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "nav-drawer-overlay";
|
||
overlay.setAttribute("role", "presentation");
|
||
|
||
const aside = document.createElement("aside");
|
||
aside.className = "nav-drawer";
|
||
aside.id = "nav-drawer";
|
||
aside.setAttribute("role", "dialog");
|
||
aside.setAttribute("aria-modal", "true");
|
||
aside.setAttribute("aria-label", "Navigation menu");
|
||
|
||
aside.innerHTML = `
|
||
<div class="nav-drawer-head">
|
||
<span class="nav-drawer-title">Menu</span>
|
||
<button type="button" class="nav-drawer-close" aria-label="Close menu">
|
||
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="22" width="22" xmlns="http://www.w3.org/2000/svg"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||
</button>
|
||
</div>
|
||
<div class="nav-drawer-links">
|
||
${links
|
||
.map((l) => {
|
||
const extra = l.target
|
||
? ` target="${l.target}" rel="noopener noreferrer"`
|
||
: "";
|
||
return `<a href="${l.href}"${extra}>${l.label}</a>`;
|
||
})
|
||
.join("")}
|
||
</div>
|
||
<a href="#cta" class="btn btn-primary nav-drawer-cta" data-demo-open="true">Request demo</a>
|
||
`;
|
||
|
||
overlay.appendChild(aside);
|
||
document.body.appendChild(overlay);
|
||
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) closeDrawer();
|
||
});
|
||
aside
|
||
.querySelector(".nav-drawer-close")
|
||
?.addEventListener("click", () => closeDrawer());
|
||
aside.querySelectorAll("a").forEach((a) => {
|
||
a.addEventListener("click", () => closeDrawer());
|
||
});
|
||
|
||
drawerEls = { overlay, aside };
|
||
return drawerEls;
|
||
}
|
||
|
||
function openDrawer() {
|
||
const { overlay, aside } = ensureDrawer();
|
||
drawerOpen = true;
|
||
menuBtn.setAttribute("aria-expanded", "true");
|
||
menuBtn.setAttribute("aria-label", "Close menu");
|
||
requestAnimationFrame(() => {
|
||
overlay.classList.add("is-open");
|
||
aside.classList.add("is-open");
|
||
});
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeDrawer(instant) {
|
||
if (!drawerEls) return;
|
||
drawerOpen = false;
|
||
menuBtn.setAttribute("aria-expanded", "false");
|
||
menuBtn.setAttribute("aria-label", "Open menu");
|
||
drawerEls.overlay.classList.remove("is-open");
|
||
drawerEls.aside.classList.remove("is-open");
|
||
document.body.style.overflow = "";
|
||
if (instant) {
|
||
drawerEls.overlay.style.transition = "none";
|
||
drawerEls.aside.style.transition = "none";
|
||
requestAnimationFrame(() => {
|
||
drawerEls.overlay.style.transition = "";
|
||
drawerEls.aside.style.transition = "";
|
||
});
|
||
}
|
||
}
|
||
|
||
menuBtn.addEventListener("click", () => {
|
||
if (drawerOpen) closeDrawer();
|
||
else openDrawer();
|
||
});
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && drawerOpen) closeDrawer();
|
||
});
|
||
|
||
syncDrawerMode();
|
||
mq.addEventListener("change", syncDrawerMode);
|
||
}
|
||
|
||
/* ---------- Demo modal ---------- */
|
||
function validateDemo(data) {
|
||
const errors = {};
|
||
const name = (data.name || "").trim();
|
||
const mobile = (data.mobile || "").trim();
|
||
const email = (data.email || "").trim();
|
||
const message = (data.message || "").trim();
|
||
const captchaAnswer = (data.captchaAnswer || "").trim();
|
||
|
||
if (!name) errors.name = "Full name is required.";
|
||
if (!mobile) errors.mobile = "Mobile is required.";
|
||
else {
|
||
const digits = mobile.replace(/[\s\-().]/g, "");
|
||
if (!MOBILE_RE.test(digits)) {
|
||
errors.mobile = "Enter a valid mobile number (10-15 digits).";
|
||
}
|
||
}
|
||
if (!email) errors.email = "Email is required.";
|
||
else if (!EMAIL_RE.test(email)) {
|
||
errors.email = "Enter a valid email address.";
|
||
}
|
||
if (!message) errors.message = "Message is required.";
|
||
if (!captchaAnswer) errors.captcha = "Please solve the captcha.";
|
||
|
||
return {
|
||
ok: Object.keys(errors).length === 0,
|
||
errors,
|
||
data: {
|
||
name,
|
||
mobile: mobile.replace(/[\s\-().]/g, ""),
|
||
email,
|
||
message,
|
||
captchaAnswer,
|
||
},
|
||
};
|
||
}
|
||
|
||
function initDemoModal() {
|
||
let overlay = null;
|
||
let open = false;
|
||
let success = false;
|
||
let captchaToken = "";
|
||
let captchaLoading = false;
|
||
let submitting = false;
|
||
|
||
async function loadCaptcha() {
|
||
if (!overlay) return;
|
||
const questionEl = overlay.querySelector("[data-demo-captcha-question]");
|
||
const answerEl = overlay.querySelector('[name="captchaAnswer"]');
|
||
const reloadBtn = overlay.querySelector("[data-demo-captcha-reload]");
|
||
if (!questionEl) return;
|
||
|
||
captchaLoading = true;
|
||
questionEl.textContent = "Loading…";
|
||
if (answerEl) answerEl.value = "";
|
||
if (reloadBtn) reloadBtn.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/public/captcha/challenge`, {
|
||
cache: "no-store",
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok || !body?.success || !body?.data?.captchaToken) {
|
||
throw new Error(body?.error?.message || "Captcha unavailable");
|
||
}
|
||
captchaToken = body.data.captchaToken;
|
||
questionEl.textContent = body.data.question || "Solve the sum";
|
||
} catch {
|
||
captchaToken = "";
|
||
questionEl.textContent = "Unavailable — tap reload";
|
||
} finally {
|
||
captchaLoading = false;
|
||
if (reloadBtn) reloadBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function setFieldError(form, key, message) {
|
||
const inputName = key === "captcha" ? "captchaAnswer" : key;
|
||
const errId = key === "captchaAnswer" || key === "captcha" ? "demo-captcha-err" : `demo-${key}-err`;
|
||
const field = form.querySelector(`[name="${inputName}"]`)?.closest(".demo-field");
|
||
const errEl = document.getElementById(errId);
|
||
const input = form.querySelector(`[name="${inputName}"]`);
|
||
if (message) {
|
||
field?.classList.add("has-error");
|
||
if (errEl) {
|
||
errEl.hidden = false;
|
||
errEl.textContent = message;
|
||
}
|
||
input?.setAttribute("aria-invalid", "true");
|
||
} else {
|
||
field?.classList.remove("has-error");
|
||
if (errEl) {
|
||
errEl.hidden = true;
|
||
errEl.textContent = "";
|
||
}
|
||
input?.removeAttribute("aria-invalid");
|
||
}
|
||
}
|
||
|
||
function build() {
|
||
if (overlay) return overlay;
|
||
|
||
overlay = document.createElement("div");
|
||
overlay.className = "demo-modal-overlay";
|
||
overlay.setAttribute("role", "presentation");
|
||
overlay.innerHTML = `
|
||
<div class="demo-modal" role="dialog" aria-modal="true" aria-labelledby="demo-modal-title">
|
||
<button type="button" class="demo-modal-close" aria-label="Close">×</button>
|
||
<div class="demo-modal-form-view">
|
||
<div class="demo-modal-head">
|
||
<h2 id="demo-modal-title">Request a demo</h2>
|
||
<p>Tell us a bit about your event and we’ll get back to you.</p>
|
||
</div>
|
||
<form class="demo-modal-form" novalidate>
|
||
<div class="demo-field">
|
||
<span>Name</span>
|
||
<input id="demo-name" name="name" type="text" autocomplete="name" placeholder="Full name" required />
|
||
<p class="demo-field-error" id="demo-name-err" hidden></p>
|
||
</div>
|
||
<div class="demo-field">
|
||
<span>Mobile</span>
|
||
<input id="demo-mobile" name="mobile" type="tel" autocomplete="tel" placeholder="Mobile number" required />
|
||
<p class="demo-field-error" id="demo-mobile-err" hidden></p>
|
||
</div>
|
||
<div class="demo-field">
|
||
<span>Email</span>
|
||
<input id="demo-email" name="email" type="email" autocomplete="email" placeholder="Work email" required />
|
||
<p class="demo-field-error" id="demo-email-err" hidden></p>
|
||
</div>
|
||
<div class="demo-field">
|
||
<span>Message</span>
|
||
<textarea id="demo-message" name="message" rows="4" placeholder="Tell us about your event" required></textarea>
|
||
<p class="demo-field-error" id="demo-message-err" hidden></p>
|
||
</div>
|
||
<div class="demo-field demo-captcha-field">
|
||
<span>Security check</span>
|
||
<div class="demo-captcha-row">
|
||
<span class="demo-captcha-question" data-demo-captcha-question aria-live="polite">Loading…</span>
|
||
<input
|
||
id="demo-captcha"
|
||
name="captchaAnswer"
|
||
type="text"
|
||
inputmode="numeric"
|
||
autocomplete="off"
|
||
placeholder="Answer"
|
||
required
|
||
/>
|
||
<button type="button" class="demo-captcha-reload" data-demo-captcha-reload aria-label="Reload captcha">↻</button>
|
||
</div>
|
||
<p class="demo-field-error" id="demo-captcha-err" hidden></p>
|
||
</div>
|
||
<p class="demo-form-error" hidden></p>
|
||
<div class="demo-modal-actions">
|
||
<button type="submit" class="btn btn-primary demo-submit-btn">Request demo</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div class="demo-modal-success" hidden>
|
||
<h2>Thanks — we'll be in touch</h2>
|
||
<p>Your demo request was received. Our team will reach out shortly.</p>
|
||
<div class="demo-modal-actions">
|
||
<button type="button" class="btn btn-primary demo-success-close">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) close();
|
||
});
|
||
overlay
|
||
.querySelector(".demo-modal-close")
|
||
?.addEventListener("click", close);
|
||
overlay
|
||
.querySelector(".demo-success-close")
|
||
?.addEventListener("click", close);
|
||
overlay
|
||
.querySelector("[data-demo-captcha-reload]")
|
||
?.addEventListener("click", () => {
|
||
void loadCaptcha();
|
||
});
|
||
|
||
const form = overlay.querySelector(".demo-modal-form");
|
||
form.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (submitting || captchaLoading) return;
|
||
|
||
const fd = new FormData(form);
|
||
const result = validateDemo({
|
||
name: fd.get("name"),
|
||
mobile: fd.get("mobile"),
|
||
email: fd.get("email"),
|
||
message: fd.get("message"),
|
||
captchaAnswer: fd.get("captchaAnswer"),
|
||
});
|
||
|
||
["name", "mobile", "email", "message", "captchaAnswer"].forEach((key) => {
|
||
const errKey = key === "captchaAnswer" ? "captcha" : key;
|
||
setFieldError(form, key, result.errors[errKey] || "");
|
||
});
|
||
|
||
const formErr = overlay.querySelector(".demo-form-error");
|
||
if (formErr) {
|
||
formErr.hidden = true;
|
||
formErr.textContent = "";
|
||
}
|
||
|
||
if (!result.ok) {
|
||
const first =
|
||
["name", "mobile", "email", "message", "captcha"].find(
|
||
(k) => result.errors[k]
|
||
) || "name";
|
||
const focusName = first === "captcha" ? "captchaAnswer" : first;
|
||
form.querySelector(`[name="${focusName}"]`)?.focus();
|
||
return;
|
||
}
|
||
|
||
if (!captchaToken) {
|
||
setFieldError(
|
||
form,
|
||
"captchaAnswer",
|
||
"Captcha is not ready. Please reload and try again."
|
||
);
|
||
void loadCaptcha();
|
||
return;
|
||
}
|
||
|
||
const submitBtn = overlay.querySelector(".demo-submit-btn");
|
||
submitting = true;
|
||
if (submitBtn) {
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = "Sending…";
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/public/demo-request`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: result.data.name,
|
||
mobile: result.data.mobile,
|
||
email: result.data.email,
|
||
message: result.data.message,
|
||
captchaToken,
|
||
captchaAnswer: result.data.captchaAnswer,
|
||
}),
|
||
});
|
||
let body = null;
|
||
try {
|
||
body = await res.json();
|
||
} catch {
|
||
body = null;
|
||
}
|
||
|
||
if (!res.ok || !body?.success) {
|
||
const msg =
|
||
body?.error?.message ||
|
||
"Could not send your request. Please try again.";
|
||
if (/captcha/i.test(msg)) {
|
||
setFieldError(form, "captchaAnswer", msg);
|
||
void loadCaptcha();
|
||
} else if (formErr) {
|
||
formErr.hidden = false;
|
||
formErr.textContent = msg;
|
||
}
|
||
return;
|
||
}
|
||
|
||
showSuccess();
|
||
} catch {
|
||
if (formErr) {
|
||
formErr.hidden = false;
|
||
formErr.textContent =
|
||
"Could not reach the server. Please try again in a moment.";
|
||
}
|
||
} finally {
|
||
submitting = false;
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = "Request demo";
|
||
}
|
||
}
|
||
});
|
||
|
||
return overlay;
|
||
}
|
||
|
||
function showSuccess() {
|
||
success = true;
|
||
const formView = overlay.querySelector(".demo-modal-form-view");
|
||
const successView = overlay.querySelector(".demo-modal-success");
|
||
if (formView) formView.hidden = true;
|
||
if (successView) successView.hidden = false;
|
||
}
|
||
|
||
function reset() {
|
||
success = false;
|
||
captchaToken = "";
|
||
const form = overlay?.querySelector(".demo-modal-form");
|
||
form?.reset();
|
||
overlay?.querySelectorAll(".demo-field-error").forEach((el) => {
|
||
el.hidden = true;
|
||
el.textContent = "";
|
||
});
|
||
overlay?.querySelectorAll(".demo-field").forEach((el) => {
|
||
el.classList.remove("has-error");
|
||
});
|
||
const formErr = overlay?.querySelector(".demo-form-error");
|
||
if (formErr) {
|
||
formErr.hidden = true;
|
||
formErr.textContent = "";
|
||
}
|
||
const formView = overlay?.querySelector(".demo-modal-form-view");
|
||
const successView = overlay?.querySelector(".demo-modal-success");
|
||
if (formView) formView.hidden = false;
|
||
if (successView) successView.hidden = true;
|
||
void loadCaptcha();
|
||
}
|
||
|
||
function openModal() {
|
||
const el = build();
|
||
if (!open) reset();
|
||
else if (!captchaToken) void loadCaptcha();
|
||
open = true;
|
||
document.body.style.overflow = "hidden";
|
||
requestAnimationFrame(() => {
|
||
el.classList.add("is-open");
|
||
el.querySelector(".demo-modal")?.classList.add("is-open");
|
||
if (!success) {
|
||
el.querySelector('[name="name"]')?.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
function close() {
|
||
if (!overlay) return;
|
||
open = false;
|
||
overlay.classList.remove("is-open");
|
||
overlay.querySelector(".demo-modal")?.classList.remove("is-open");
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
document.addEventListener("click", (e) => {
|
||
const trigger = e.target.closest("[data-demo-open]");
|
||
if (!trigger) return;
|
||
e.preventDefault();
|
||
openModal();
|
||
});
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && open) close();
|
||
});
|
||
}
|
||
|
||
/* ---------- Scroll reveals (GSAP ScrollTrigger when available) ---------- */
|
||
function initReveals() {
|
||
const nodes = document.querySelectorAll(".reveal");
|
||
if (!nodes.length) return;
|
||
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||
return;
|
||
}
|
||
|
||
if (window.gsap && window.ScrollTrigger) {
|
||
window.gsap.registerPlugin(window.ScrollTrigger);
|
||
nodes.forEach((el) => {
|
||
const y = Number(el.dataset.revealY ?? 28);
|
||
const duration = Number(el.dataset.revealDuration ?? 0.7);
|
||
const delay = Number(el.dataset.revealDelay ?? 0);
|
||
window.gsap.set(el, { opacity: 0, y, willChange: "opacity, transform" });
|
||
window.gsap.to(el, {
|
||
opacity: 1,
|
||
y: 0,
|
||
duration,
|
||
delay,
|
||
ease: "power2.out",
|
||
scrollTrigger: {
|
||
trigger: el,
|
||
start: "top 88%",
|
||
end: "bottom top",
|
||
toggleActions: "play reverse play reverse",
|
||
},
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Fallback without GSAP
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.style.opacity = "1";
|
||
entry.target.style.transform = "none";
|
||
}
|
||
});
|
||
},
|
||
{ threshold: 0.12, rootMargin: "0px 0px -12% 0px" }
|
||
);
|
||
nodes.forEach((el) => {
|
||
el.style.opacity = "0";
|
||
el.style.transform = "translateY(28px)";
|
||
el.style.transition = "opacity .7s ease, transform .7s ease";
|
||
io.observe(el);
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initNav();
|
||
initDemoModal();
|
||
initReveals();
|
||
});
|
||
})();
|