Eventbuzz_MainWebsite/js/site.js

550 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
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">&times;</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();
});
})();