Eventbuzz_MainWebsite/js/site.js

1543 lines
49 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();
function getHashId(anchor) {
const raw = anchor.getAttribute("href") || "";
if (!raw || raw === "#") return "";
try {
const url = new URL(anchor.href, window.location.href);
if (!url.hash || url.hash === "#") return "";
return decodeURIComponent(url.hash.slice(1));
} catch {
return raw.charAt(0) === "#" ? raw.slice(1) : "";
}
}
let activeNavId = "";
let navSpyLocked = false;
let navSpyUnlockTimer = 0;
function setActiveNav(id) {
activeNavId = id || "";
document
.querySelectorAll(".navlinks-desktop a, .nav-drawer-links a")
.forEach((a) => {
if (a.target === "_blank") return;
const linkId = getHashId(a);
if (!linkId) return;
const on = Boolean(activeNavId) && linkId === activeNavId;
a.classList.toggle("is-active", on);
if (on) a.setAttribute("aria-current", "location");
else a.removeAttribute("aria-current");
});
}
function lockActiveNav(id) {
navSpyLocked = true;
setActiveNav(id);
window.clearTimeout(navSpyUnlockTimer);
navSpyUnlockTimer = window.setTimeout(() => {
navSpyLocked = false;
}, 900);
}
/* ---------- 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: 1024px)");
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();
setActiveNav(activeNavId);
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);
}
/* ---------- In-page section links (no #hash in the URL) ---------- */
function initInPageNav() {
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const PENDING_KEY = "eb-scroll-to";
function normalizePath(path) {
return path.replace(/\/index\.html$/i, "/") || "/";
}
function scrollToId(id) {
const el = document.getElementById(id);
if (!el) return false;
lockActiveNav(id);
el.scrollIntoView({
behavior: reduceMotion ? "auto" : "smooth",
block: "start",
});
return true;
}
function consumeHash() {
if (!window.location.hash || window.location.hash === "#") return "";
const id = decodeURIComponent(window.location.hash.slice(1));
history.replaceState(
null,
"",
window.location.pathname + window.location.search
);
return id;
}
let pending = "";
try {
pending = sessionStorage.getItem(PENDING_KEY) || "";
if (pending) sessionStorage.removeItem(PENDING_KEY);
} catch {
pending = "";
}
const fromUrl = consumeHash();
const target = pending || fromUrl;
if (target) {
const go = () => scrollToId(target);
if (document.readyState === "complete") requestAnimationFrame(go);
else window.addEventListener("load", () => requestAnimationFrame(go), { once: true });
}
document.addEventListener("click", (e) => {
if (
e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
return;
}
const a = e.target.closest("a[href]");
if (!a || a.target === "_blank" || a.hasAttribute("download")) return;
if (a.hasAttribute("data-demo-open") || a.hasAttribute("data-talk-open")) {
return;
}
const raw = a.getAttribute("href") || "";
if (!raw || raw === "#") return;
let url;
try {
url = new URL(a.href);
} catch {
return;
}
if (!url.hash || url.hash === "#") return;
const id = decodeURIComponent(url.hash.slice(1));
if (!id) return;
if (
url.origin === window.location.origin &&
normalizePath(url.pathname) === normalizePath(window.location.pathname)
) {
if (!document.getElementById(id)) return;
e.preventDefault();
scrollToId(id);
return;
}
if (url.origin !== window.location.origin) return;
try {
sessionStorage.setItem(PENDING_KEY, id);
} catch {
return;
}
e.preventDefault();
window.location.assign(url.pathname + url.search);
});
}
/* ---------- Highlight nav link for the section in view ---------- */
function initNavSpy() {
const links = Array.from(
document.querySelectorAll(".navlinks-desktop a")
).filter((a) => a.target !== "_blank" && getHashId(a));
const items = links
.map((a) => {
const id = getHashId(a);
const el = document.getElementById(id);
return el ? { id, el } : null;
})
.filter(Boolean)
.sort((a, b) => {
const pos = a.el.compareDocumentPosition(b.el);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
if (!items.length) return;
function currentId() {
const nav = document.querySelector("nav");
const marker = (nav ? nav.offsetHeight : 58) + 20;
let id = "";
items.forEach((item) => {
if (item.el.getBoundingClientRect().top <= marker) id = item.id;
});
return id;
}
let ticking = false;
function update() {
ticking = false;
if (navSpyLocked) return;
setActiveNav(currentId());
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(update);
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update();
}
/* ---------- Demo modal ---------- */
const EVENT_TYPES = [
"Webinar",
"Conference",
"Exhibition",
"Internal Sales Meet",
"Others",
];
function validateDemo(data) {
const errors = {};
const name = (data.name || "").trim();
const mobile = (data.mobile || "").trim();
const email = (data.email || "").trim();
const eventType = (data.eventType || "").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 (!eventType) errors.eventType = "Select an event type.";
else if (!EVENT_TYPES.includes(eventType)) {
errors.eventType = "Select a valid event type.";
}
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,
eventType,
message,
captchaAnswer,
},
};
}
function bindDemoForm(root, options = {}) {
const idPrefix = options.idPrefix || "demo";
let captchaToken = "";
let captchaLoading = false;
let submitting = false;
let success = false;
async function loadCaptcha() {
if (!root) return;
const questionEl = root.querySelector("[data-demo-captcha-question]");
const answerEl = root.querySelector('[name="captchaAnswer"]');
const reloadBtn = root.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"
? `${idPrefix}-captcha-err`
: `${idPrefix}-${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 showSuccess() {
success = true;
const formView = root.querySelector(".demo-modal-form-view");
const successView = root.querySelector(".demo-modal-success");
if (formView) formView.hidden = true;
if (successView) successView.hidden = false;
}
function reset() {
success = false;
captchaToken = "";
const form = root.querySelector(".demo-modal-form");
form?.reset();
root.querySelectorAll(".demo-field-error").forEach((el) => {
el.hidden = true;
el.textContent = "";
});
root.querySelectorAll(".demo-field").forEach((el) => {
el.classList.remove("has-error");
});
const formErr = root.querySelector(".demo-form-error");
if (formErr) {
formErr.hidden = true;
formErr.textContent = "";
}
const formView = root.querySelector(".demo-modal-form-view");
const successView = root.querySelector(".demo-modal-success");
if (formView) formView.hidden = false;
if (successView) successView.hidden = true;
void loadCaptcha();
}
const form = root.querySelector(".demo-modal-form");
if (!form) {
return { loadCaptcha, reset, isSuccess: () => success };
}
root
.querySelector("[data-demo-captcha-reload]")
?.addEventListener("click", () => {
void loadCaptcha();
});
root.querySelector("[data-demo-reset]")?.addEventListener("click", reset);
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"),
eventType: fd.get("eventType"),
message: fd.get("message"),
captchaAnswer: fd.get("captchaAnswer"),
});
["name", "mobile", "email", "eventType", "message", "captchaAnswer"].forEach(
(key) => {
const errKey = key === "captchaAnswer" ? "captcha" : key;
setFieldError(form, key, result.errors[errKey] || "");
}
);
const formErr = root.querySelector(".demo-form-error");
if (formErr) {
formErr.hidden = true;
formErr.textContent = "";
}
if (!result.ok) {
const first =
["name", "mobile", "email", "eventType", "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 = root.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,
eventType: result.data.eventType,
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 { loadCaptcha, reset, isSuccess: () => success };
}
function initDemoPage() {
const root = document.querySelector("[data-demo-page]");
if (!root) return;
bindDemoForm(root, { idPrefix: "help" }).reset();
}
function initDemoModal() {
let overlay = null;
let open = false;
let formApi = null;
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>Event type</span>
<select id="demo-event-type" name="eventType" required>
<option value="" disabled selected>Select event type</option>
<option value="Webinar">Webinar</option>
<option value="Conference">Conference</option>
<option value="Exhibition">Exhibition</option>
<option value="Internal Sales Meet">Internal Sales Meet</option>
<option value="Others">Others</option>
</select>
<p class="demo-field-error" id="demo-eventType-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>We have received your request. Our team will get in touch with you 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);
formApi = bindDemoForm(overlay, { idPrefix: "demo" });
return overlay;
}
function openModal() {
const el = build();
if (!open) formApi.reset();
open = true;
document.body.style.overflow = "hidden";
requestAnimationFrame(() => {
el.classList.add("is-open");
el.querySelector(".demo-modal")?.classList.add("is-open");
if (!formApi.isSuccess()) {
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();
});
}
const PLANS = ["Start", "Grow", "Scale", "Enterprise"];
function validateTalk(data) {
const errors = {};
const name = (data.name || "").trim();
const mobile = (data.mobile || "").trim();
const email = (data.email || "").trim();
const selectedPlan = (data.selectedPlan || "").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 (!selectedPlan) errors.selectedPlan = "Selected plan is required.";
else if (!PLANS.includes(selectedPlan)) {
errors.selectedPlan = "Select a valid plan.";
}
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,
selectedPlan,
message,
captchaAnswer,
},
};
}
function initTalkModal() {
let overlay = null;
let open = false;
let success = false;
let captchaToken = "";
let captchaLoading = false;
let submitting = false;
let selectedPlan = "";
async function loadCaptcha() {
if (!overlay) return;
const questionEl = overlay.querySelector("[data-talk-captcha-question]");
const answerEl = overlay.querySelector('[name="captchaAnswer"]');
const reloadBtn = overlay.querySelector("[data-talk-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"
? "talk-captcha-err"
: `talk-${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="talk-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="talk-modal-title">Talk to our team</h2>
<p>Tell us about your event and the plan you’re interested in.</p>
</div>
<form class="demo-modal-form" novalidate>
<div class="demo-field">
<span>Name</span>
<input id="talk-name" name="name" type="text" autocomplete="name" placeholder="Full name" required />
<p class="demo-field-error" id="talk-name-err" hidden></p>
</div>
<div class="demo-field">
<span>Mobile</span>
<input id="talk-mobile" name="mobile" type="tel" autocomplete="tel" placeholder="Mobile number" required />
<p class="demo-field-error" id="talk-mobile-err" hidden></p>
</div>
<div class="demo-field">
<span>Email</span>
<input id="talk-email" name="email" type="email" autocomplete="email" placeholder="Work email" required />
<p class="demo-field-error" id="talk-email-err" hidden></p>
</div>
<div class="demo-field">
<span>Selected plan</span>
<input id="talk-plan" name="selectedPlan" type="text" readonly tabindex="-1" />
<p class="demo-field-error" id="talk-selectedPlan-err" hidden></p>
</div>
<div class="demo-field">
<span>Message</span>
<textarea id="talk-message" name="message" rows="4" placeholder="Tell us about your event" required></textarea>
<p class="demo-field-error" id="talk-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-talk-captcha-question aria-live="polite">Loading…</span>
<input
id="talk-captcha"
name="captchaAnswer"
type="text"
inputmode="numeric"
autocomplete="off"
placeholder="Answer"
required
/>
<button type="button" class="demo-captcha-reload" data-talk-captcha-reload aria-label="Reload captcha">↻</button>
</div>
<p class="demo-field-error" id="talk-captcha-err" hidden></p>
</div>
<p class="demo-form-error" hidden></p>
<div class="demo-modal-actions">
<button type="submit" class="btn btn-primary talk-submit-btn">Talk to our team</button>
</div>
</form>
</div>
<div class="demo-modal-success" hidden>
<h2>Thanks, we'll be in touch.</h2>
<p>We have received your request. Our team will get in touch with you shortly.</p>
<div class="demo-modal-actions">
<button type="button" class="btn btn-primary talk-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(".talk-success-close")?.addEventListener("click", close);
overlay.querySelector("[data-talk-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 = validateTalk({
name: fd.get("name"),
mobile: fd.get("mobile"),
email: fd.get("email"),
selectedPlan: fd.get("selectedPlan") || selectedPlan,
message: fd.get("message"),
captchaAnswer: fd.get("captchaAnswer"),
});
["name", "mobile", "email", "selectedPlan", "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", "selectedPlan", "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(".talk-submit-btn");
submitting = true;
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = "Sending…";
}
try {
const res = await fetch(`${API_BASE}/public/talk-to-team`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: result.data.name,
mobile: result.data.mobile,
email: result.data.email,
selectedPlan: result.data.selectedPlan,
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 = "Talk to our team";
}
}
});
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;
const planInput = overlay?.querySelector('[name="selectedPlan"]');
if (planInput) planInput.value = selectedPlan;
void loadCaptcha();
}
function openModal(plan) {
selectedPlan = plan;
const el = build();
if (!open) reset();
else {
const planInput = el.querySelector('[name="selectedPlan"]');
if (planInput) planInput.value = selectedPlan;
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-talk-open]");
if (!trigger) return;
e.preventDefault();
const plan =
(trigger.getAttribute("data-plan") || "").trim() ||
trigger.closest(".tier")?.querySelector("h3")?.textContent.trim() ||
"";
openModal(plan);
});
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;
}
const revealLine = () => window.innerHeight * 0.88;
const isPastRevealPoint = (el) => el.getBoundingClientRect().top < revealLine();
if (window.gsap && window.ScrollTrigger) {
const gsap = window.gsap;
const ScrollTrigger = window.ScrollTrigger;
gsap.registerPlugin(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);
gsap.fromTo(
el,
{ autoAlpha: 0, y, willChange: "opacity, transform" },
{
autoAlpha: 1,
y: 0,
duration,
delay,
ease: "power2.out",
onComplete: () => gsap.set(el, { clearProps: "willChange" }),
scrollTrigger: {
trigger: el,
start: "top 88%",
toggleActions: "play none none none",
once: true,
},
}
);
});
const showRestored = () => {
nodes.forEach((el) => {
if (isPastRevealPoint(el)) {
gsap.set(el, { autoAlpha: 1, y: 0, overwrite: true, clearProps: "willChange" });
}
});
ScrollTrigger.refresh();
};
requestAnimationFrame(showRestored);
window.addEventListener("load", showRestored, { once: true });
window.addEventListener("pageshow", showRestored);
return;
}
// Fallback without GSAP
const show = (el) => {
el.style.opacity = "1";
el.style.transform = "none";
};
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
show(entry.target);
io.unobserve(entry.target);
}
});
},
{ threshold: 0.12, rootMargin: "0px 0px -12% 0px" }
);
const syncFallback = () => {
nodes.forEach((el) => {
if (isPastRevealPoint(el)) {
show(el);
io.unobserve(el);
}
});
};
nodes.forEach((el) => {
if (isPastRevealPoint(el)) {
show(el);
return;
}
el.style.opacity = "0";
el.style.transform = "translateY(28px)";
el.style.transition = "opacity .7s ease, transform .7s ease";
io.observe(el);
});
window.addEventListener("load", syncFallback, { once: true });
window.addEventListener("pageshow", syncFallback);
}
/* ---------- Customer stories slider ---------- */
function initStoriesSlider() {
const root = document.querySelector(".stories-slider");
if (!root) return;
const viewport = root.querySelector(".stories-slider-viewport");
const track = root.querySelector(".stories-slider-track");
const slides = Array.from(root.querySelectorAll(".stories-slide"));
const buttons = root.querySelectorAll(".stories-slider-btn");
const prevBtn = buttons[0];
const nextBtn = buttons[1];
if (!viewport || !track || slides.length < 2 || !prevBtn || !nextBtn) return;
const GAP = 20;
const DEFAULT_AUTOPLAY_MS = 5000;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
let index = 0;
let dragStartX = 0;
let dragging = false;
let autoplayTimer = null;
function autoplayDelay() {
const raw = root.getAttribute("data-autoplay");
if (raw == null || raw === "") return DEFAULT_AUTOPLAY_MS;
if (/^(false|off|none)$/i.test(raw.trim())) return 0;
const ms = Number(raw);
return Number.isFinite(ms) && ms > 0 ? ms : 0;
}
function perView() {
const w = viewport.clientWidth;
if (w < 640) return 1;
if (w < 900) return 2;
return 3;
}
function maxIndex() {
return Math.max(0, slides.length - perView());
}
function stepWidth() {
const n = perView();
return (viewport.clientWidth - GAP * (n - 1)) / n + GAP;
}
function goTo(next, animate) {
index = Math.max(0, Math.min(next, maxIndex()));
const offset = index * stepWidth();
track.style.transition =
animate && !reduceMotion ? "transform .4s ease" : "none";
track.style.transform = offset ? `translateX(-${offset}px)` : "none";
prevBtn.disabled = index <= 0;
nextBtn.disabled = index >= maxIndex();
}
function stopAutoplay() {
if (autoplayTimer) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
}
function startAutoplay() {
stopAutoplay();
const delay = autoplayDelay();
if (!delay || reduceMotion || maxIndex() <= 0) return;
autoplayTimer = setInterval(() => {
const max = maxIndex();
if (max <= 0) return;
goTo(index >= max ? 0 : index + 1, true);
}, delay);
}
function goAndRestart(next) {
goTo(next, true);
startAutoplay();
}
prevBtn.addEventListener("click", () => goAndRestart(index - 1));
nextBtn.addEventListener("click", () => goAndRestart(index + 1));
viewport.addEventListener("pointerdown", (e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
dragging = true;
dragStartX = e.clientX;
stopAutoplay();
});
const endDrag = (e) => {
if (!dragging) return;
dragging = false;
const dx = e.clientX - dragStartX;
if (Math.abs(dx) >= 40) goTo(index + (dx < 0 ? 1 : -1), true);
startAutoplay();
};
viewport.addEventListener("pointerup", endDrag);
viewport.addEventListener("pointercancel", () => {
dragging = false;
startAutoplay();
});
root.addEventListener("mouseenter", stopAutoplay);
root.addEventListener("mouseleave", startAutoplay);
root.addEventListener("focusin", stopAutoplay);
root.addEventListener("focusout", (e) => {
if (!root.contains(e.relatedTarget)) startAutoplay();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAutoplay();
else startAutoplay();
});
const onResize = () => goTo(index, false);
window.addEventListener("resize", onResize);
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(onResize).observe(viewport);
}
goTo(0, false);
startAutoplay();
}
/* ---------- Back to top ---------- */
function initBackToTop() {
if (document.querySelector(".back-to-top")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "back-to-top";
btn.setAttribute("aria-label", "Back to top");
btn.innerHTML =
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>';
document.body.appendChild(btn);
const SHOW_AFTER = 400;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const sync = () => {
btn.classList.toggle("is-visible", window.scrollY > SHOW_AFTER);
};
window.addEventListener("scroll", sync, { passive: true });
sync();
btn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
});
}
/* ---------- Platform feature showcase ---------- */
function initPlatShowcase() {
const root = document.querySelector(".plat-split");
if (!root) return;
const cards = Array.from(root.querySelectorAll(".plat-card"));
const images = Array.from(root.querySelectorAll(".plat-preview img"));
const descEl = root.querySelector("[data-plat-desc]");
if (!cards.length || !images.length) return;
const DEFAULT_AUTOPLAY_MS = 5000;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const gsap = window.gsap;
const canAnimate = Boolean(gsap) && !reduceMotion;
if (canAnimate) root.classList.add("is-gsap-preview");
let index = Math.max(
0,
cards.findIndex((card) => card.classList.contains("is-active"))
);
let autoplayTimer = null;
let inView = typeof IntersectionObserver === "undefined";
function autoplayDelay() {
const raw = root.getAttribute("data-autoplay");
if (raw == null || raw === "") return DEFAULT_AUTOPLAY_MS;
if (/^(false|off|none)$/i.test(raw.trim())) return 0;
const ms = Number(raw);
return Number.isFinite(ms) && ms > 0 ? ms : 0;
}
function imageFor(i) {
const id = cards[i]?.dataset.plat;
return images.find((img) => img.dataset.plat === id) || null;
}
function setCards(i) {
cards.forEach((card, idx) => {
const on = idx === i;
card.classList.toggle("is-active", on);
card.setAttribute("aria-selected", on ? "true" : "false");
});
if (descEl) {
descEl.textContent =
cards[i]?.querySelector("p")?.textContent.trim() || "";
}
}
function direction(from, to) {
const n = cards.length;
if (from === n - 1 && to === 0) return 1;
if (from === 0 && to === n - 1) return -1;
return to > from ? 1 : -1;
}
function showInstant(nextIndex) {
index = nextIndex;
setCards(index);
images.forEach((img) => {
const on = img === imageFor(index);
img.classList.toggle("is-active", on);
if (gsap) {
gsap.killTweensOf(img);
gsap.set(img, { autoAlpha: on ? 1 : 0, x: 0, zIndex: on ? 1 : 0 });
}
});
}
function show(nextIndex, animate) {
if (nextIndex === index) return;
const prevImg = imageFor(index);
const nextImg = imageFor(nextIndex);
if (!nextImg) return;
if (!canAnimate || !animate || !prevImg) {
showInstant(nextIndex);
return;
}
const dir = direction(index, nextIndex);
index = nextIndex;
setCards(index);
gsap.killTweensOf(images);
images.forEach((img) => {
if (img !== prevImg && img !== nextImg) {
img.classList.remove("is-active");
gsap.set(img, { autoAlpha: 0, x: 0, zIndex: 0 });
}
});
nextImg.classList.add("is-active");
prevImg.classList.remove("is-active");
gsap.set(prevImg, { zIndex: 1, autoAlpha: 1, x: 0 });
gsap.set(nextImg, { zIndex: 2, autoAlpha: 0, x: 40 * dir });
gsap.to(prevImg, {
autoAlpha: 0,
x: -32 * dir,
duration: 0.45,
ease: "power2.inOut",
onComplete: () => {
gsap.set(prevImg, { x: 0, zIndex: 0 });
},
});
gsap.to(nextImg, {
autoAlpha: 1,
x: 0,
duration: 0.5,
ease: "power2.out",
});
}
function stopAutoplay() {
if (autoplayTimer) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
}
function startAutoplay() {
stopAutoplay();
const delay = autoplayDelay();
if (!delay || !inView || document.hidden || cards.length < 2) return;
autoplayTimer = setInterval(() => {
show((index + 1) % cards.length, true);
}, delay);
}
function goAndRestart(nextIndex) {
show(nextIndex, true);
startAutoplay();
}
cards.forEach((card, i) => {
card.addEventListener("click", () => goAndRestart(i));
});
root.addEventListener("mouseenter", stopAutoplay);
root.addEventListener("mouseleave", startAutoplay);
root.addEventListener("focusin", stopAutoplay);
root.addEventListener("focusout", (e) => {
if (!root.contains(e.relatedTarget)) startAutoplay();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAutoplay();
else startAutoplay();
});
if (typeof IntersectionObserver !== "undefined") {
const io = new IntersectionObserver(
(entries) => {
inView = entries.some((entry) => entry.isIntersecting);
if (inView) startAutoplay();
else stopAutoplay();
},
{ threshold: 0.35 }
);
io.observe(root);
}
showInstant(index);
startAutoplay();
}
document.addEventListener("DOMContentLoaded", () => {
initNav();
initInPageNav();
initNavSpy();
initDemoPage();
initDemoModal();
initTalkModal();
initReveals();
initStoriesSlider();
initPlatShowcase();
initBackToTop();
});
})();