1document.getElementById("year").textContent = new Date().getFullYear(); 2 3// Scroll-reveal animations 4const revealObserver = new IntersectionObserver( 5 (entries) => { 6 entries.forEach((entry) => { 7 if (entry.isIntersecting) { 8 entry.target.classList.add("is-visible"); 9 revealObserver.unobserve(entry.target); 10 } 11 }); 12 }, 13 { threshold: 0.15, rootMargin: "0px 0px -40px 0px" } 14); 15document.querySelectorAll(".reveal, .reveal-stagger").forEach((el) => revealObserver.observe(el)); 16 17// Animated count-up for stats and dashboard mockup numbers 18function animateCounter(el) { 19 const target = parseFloat(el.dataset.target); 20 const prefix = el.dataset.prefix || ""; 21 const suffix = el.dataset.suffix || ""; 22 const decimals = el.dataset.decimals ? parseInt(el.dataset.decimals, 10) : 0; 23 const duration = 1400; 24 const start = performance.now(); 25 26 function tick(now) { 27 const progress = Math.min((now - start) / duration, 1); 28 const eased = 1 - Math.pow(1 - progress, 3); 29 const value = target * eased; 30 const formatted = decimals 31 ? value.toFixed(decimals) 32 : Math.round(value).toLocaleString(); 33 el.textContent = `${prefix}${formatted}${suffix}`; 34 if (progress < 1) requestAnimationFrame(tick); 35 } 36 el.dataset.animated = "1"; 37 requestAnimationFrame(tick); 38} 39 40const counterObserver = new IntersectionObserver( 41 (entries) => { 42 entries.forEach((entry) => { 43 if (entry.isIntersecting) { 44 animateCounter(entry.target); 45 counterObserver.unobserve(entry.target); 46 } 47 }); 48 }, 49 { threshold: 0.4 } 50); 51document.querySelectorAll(".counter-value").forEach((el) => counterObserver.observe(el)); 52 53// Live client/site counts from /api/stats, which reads them from the WS Insights 54// app. The figures written into index.html are the fallback: if this is slow, 55// unconfigured or failing, the published numbers simply stay on screen, so the 56// trust strip is never blank or zeroed. 57const liveStatEls = document.querySelectorAll(".counter-value[data-stat]"); 58if (liveStatEls.length) { 59 fetch("/api/stats", { headers: { Accept: "application/json" } }) 60 .then((res) => (res.ok ? res.json() : null)) 61 .then((stats) => { 62 if (!stats) return; 63 liveStatEls.forEach((el) => { 64 const value = stats[el.dataset.stat]; 65 if (!Number.isFinite(value) || value <= 0) return; 66 if (String(value) === el.dataset.target) return; 67 el.dataset.target = String(value); 68 // The visitor may have scrolled past before this resolved, in which case 69 // the counter has already run to the fallback figure â re-run it so what 70 // is on screen matches the data. Otherwise the observer picks the new 71 // target up when it fires. 72 if (el.dataset.animated === "1") animateCounter(el); 73 }); 74 }) 75 .catch(() => { 76 /* Fallback figures stay on screen â nothing to do. */ 77 }); 78} 79 80// Client logo carousel â progressive enhancement. The markup renders as a 81// centred wrapped row on its own; the marquee only switches on after cloning 82// the set, so the animation always has two identical halves and the -50% loop 83// is seamless. 84document.querySelectorAll("[data-logo-carousel]").forEach((box) => { 85 const track = box.querySelector(".logo-track"); 86 if (!track) return; 87 88 const originals = Array.from(track.children); 89 if (originals.length < 3) return; // not enough to be worth scrolling 90 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; 91 92 originals.forEach((node) => { 93 const clone = node.cloneNode(true); 94 // The clones are decorative duplicates â hide them from assistive tech 95 // and from tab order so the logos aren't announced twice. 96 clone.setAttribute("aria-hidden", "true"); 97 clone.setAttribute("data-logo-clone", ""); 98 clone.removeAttribute("id"); 99 track.appendChild(clone); 100 }); 101 102 // Pace it by content width so adding logos doesn't speed the loop up. 103 function setDuration() { 104 const half = track.scrollWidth / 2; 105 if (!half) return; 106 const pxPerSecond = 55; 107 track.style.setProperty( 108 "--logo-duration", 109 `${Math.max(20, Math.round(half / pxPerSecond))}s` 110 ); 111 } 112 113 box.classList.add("is-live"); 114 setDuration(); 115 116 // Images are lazy-loaded, so widths firm up as they arrive. 117 const imgs = track.querySelectorAll("img"); 118 let pending = imgs.length; 119 const settle = () => { 120 pending -= 1; 121 if (pending <= 0) setDuration(); 122 };
123 imgs.forEach((img) => { 124 if (img.complete) { 125 pending -= 1; 126 } else { 127 img.addEventListener("load", settle, { once: true }); 128 img.addEventListener("error", settle, { once: true }); 129 } 130 }); 131 if (pending <= 0) setDuration(); 132 133 let resizeTimer; 134 window.addEventListener("resize", () => { 135 clearTimeout(resizeTimer); 136 resizeTimer = setTimeout(setDuration, 200); 137 }); 138}); 139 140// ROI / time-savings calculator 141const calcSites = document.getElementById("calc-sites"); 142const calcHours = document.getElementById("calc-hours"); 143if (calcSites && calcHours) { 144 const calcSitesValue = document.getElementById("calc-sites-value"); 145 const calcHoursValue = document.getElementById("calc-hours-value"); 146 const hoursSavedEl = document.getElementById("calc-hours-saved"); 147 const daysSavedEl = document.getElementById("calc-days-saved"); 148 const SAVINGS_RATE = 0.9; 149 150 function updateCalculator() { 151 const sites = parseFloat(calcSites.value); 152 const hoursPerSite = parseFloat(calcHours.value); 153 calcSitesValue.textContent = sites; 154 calcHoursValue.textContent = hoursPerSite; 155 156 const totalHoursPerWeek = sites * hoursPerSite; 157 const savedHoursPerWeek = totalHoursPerWeek * SAVINGS_RATE; 158 const savedHoursPerMonth = Math.round(savedHoursPerWeek * 4.33); 159 const savedDaysPerYear = Math.round((savedHoursPerWeek * 52) / 8); 160 161 hoursSavedEl.textContent = savedHoursPerMonth.toLocaleString(); 162 daysSavedEl.textContent = savedDaysPerYear.toLocaleString(); 163 } 164 165 [calcSites, calcHours].forEach((input) => input.addEventListener("input", updateCalculator)); 166 updateCalculator(); 167} 168 169// Mobile nav toggle 170const navToggle = document.getElementById("nav-toggle"); 171const mainNav = document.getElementById("main-nav"); 172navToggle.addEventListener("click", () => { 173 const isOpen = mainNav.classList.toggle("open"); 174 navToggle.setAttribute("aria-expanded", String(isOpen)); 175}); 176mainNav.querySelectorAll("a").forEach((link) => { 177 link.addEventListener("click", () => { 178 mainNav.classList.remove("open"); 179 navToggle.setAttribute("aria-expanded", "false"); 180 }); 181}); 182 183// Interactive demo â report tabs 184const demoTabs = document.querySelectorAll(".demo-tab"); 185const demoPanels = document.querySelectorAll(".demo-panel"); 186demoTabs.forEach((tab) => { 187 tab.addEventListener("click", () => { 188 const target = tab.dataset.panel; 189 demoTabs.forEach((t) => { 190 t.classList.toggle("is-active", t === tab); 191 t.setAttribute("aria-selected", String(t === tab)); 192 }); 193 demoPanels.forEach((p) => p.classList.toggle("is-active", p.dataset.panel === target)); 194 }); 195}); 196 197// Interactive demo â generic toggle groups (Executive Finance Pack compare, Site period) 198document.querySelectorAll(".demo-toggle").forEach((group) => { 199 const buttons = group.querySelectorAll(".demo-toggle-btn"); 200 buttons.forEach((btn) => { 201 btn.addEventListener("click", () => { 202 buttons.forEach((b) => b.classList.toggle("is-active", b === btn)); 203 const key = group.dataset.toggle; 204 const value = btn.dataset.value; 205 206 if (key === "efp-compare") { 207 document.querySelector(".demo-table").setAttribute("data-compare", value); 208 } 209 210 if (key === "site-period") { 211 const labels = { week: "This Week", month: "This Month", quarter: "This Quarter" }; 212 document.getElementById("site-period-label").textContent = labels[value]; 213 document.querySelectorAll(".demo-chart-bar").forEach((bar) => { 214 const pct = bar.dataset[value]; 215 bar.querySelector(".demo-chart-fill").style.setProperty("--fill", `${pct}%`); 216 }); 217 } 218 }); 219 }); 220}); 221 222// Interactive demo â EFP table row expand/collapse 223document.querySelectorAll(".demo-row-toggle").forEach((toggle) => { 224 toggle.addEventListener("click", () => { 225 const subrow = document.querySelector(`.demo-table-sub[data-subrow="${toggle.dataset.target}"]`); 226 const isOpen = toggle.classList.toggle("is-open"); 227 subrow.classList.toggle("is-collapsed", !isOpen); 228 toggle.textContent = isOpen ? "â" : "+"; 229 toggle.setAttribute("aria-expanded", String(isOpen)); 230 }); 231}); 232 233// Interactive demo â Daily Dish accordion 234document.querySelectorAll(".demo-site-row").forEach((row) => { 235 row.addEventListener("click", () => { 236 const detail = document.querySelector(`.demo-site-detail[data-detail="${row.dataset.site}"]`); 237 const isOpen = row.classList.toggle("is-open"); 238 detail.classList.toggle("is-open", isOpen); 239 }); 240}); 241 242// Set initial chart bar heights (default: This Week) 243document.querySelectorAll(".demo-chart-bar").forEach((bar) => { 244 bar.querySelector(".demo-chart-fill").style.setProperty("--fill", `${bar.dataset.week}%`); 245}); 246 247// Contact form submission â posted directly to FormSubmit from the browser. 248// (Not proxied through a server function: FormSubmit sits behind Cloudflare, 249// which blocks/challenges server-to-server requests from datacenter IPs like 250// Vercel's. Real browser requests aren't affected.) 251const CONTACT_EMAIL = "[email protected]"; 252const form = document.getElementById("contact-form"); 253const status = document.getElementById("form-status"); 254const submitBtn = document.getElementById("contact-submit"); 255 256form.addEventListener("submit", async (e) => { 257 e.preventDefault(); 258 status.textContent = ""; 259 status.className = "form-status"; 260 submitBtn.disabled = true; 261 submitBtn.textContent = "Sending..."; 262 263 const data = Object.fromEntries(new FormData(form).entries()); 264 data.newsletter = form.newsletter.checked; 265 266 try { 267 const res = await fetch(`https://formsubmit.co/ajax/${encodeURIComponent(CONTACT_EMAIL)}`, { 268 method: "POST", 269 headers: { "Content-Type": "application/json", Accept: "application/json" }, 270 body: JSON.stringify({ 271 _subject: `WS Insights enquiry: ${data.subject}`, 272 name: `${data.firstName} ${data.lastName}`, 273 email: data.email, 274 company: data.company || "-", 275 subject: data.subject, 276 message: data.message, 277 newsletter: data.newsletter ? "yes" : "no", 278 }), 279 }); 280 const body = await res.json().catch(() => null); 281 if (!res.ok || !body || body.success !== "true") throw new Error("Request failed"); 282 283 // Best-effort: also record the lead in our database. This must never 284 // block or fail the user-facing success message above â the email via 285 // FormSubmit is already the source of truth for "did this send". 286 fetch("/api/lead", { 287 method: "POST", 288 headers: { "Content-Type": "application/json" }, 289 body: JSON.stringify(data), 290 }).catch((err) => console.error("Failed to record lead", err)); 291 292 status.textContent = "Thanks â we've got your message and will be in touch shortly."; 293 status.className = "form-status success"; 294 form.reset(); 295 } catch (err) {
296 status.textContent = "Something went wrong sending your message. Please email us directly."; 297 status.className = "form-status error"; 298 } finally { 299 submitBtn.disabled = false; 300 submitBtn.textContent = "Submit"; 301 } 302}); 303 304// Nora chat widget 305(function () { 306 const MAX_HISTORY = 12; 307 const widget = document.getElementById("nora-widget"); 308 if (!widget) return; 309 310 const launcher = document.getElementById("nora-launcher"); 311 const launcherText = document.getElementById("nora-launcher-text"); 312 const panel = document.getElementById("nora-panel"); 313 const closeBtn = document.getElementById("nora-close"); 314 const messagesEl = document.getElementById("nora-messages"); 315 const suggestEl = document.getElementById("nora-suggest"); 316 const form = document.getElementById("nora-form"); 317 const input = document.getElementById("nora-input"); 318 const sendBtn = document.getElementById("nora-send"); 319 320 let history = []; 321 let loading = false; 322 323 function setOpen(open) { 324 panel.hidden = !open; 325 launcherText.textContent = open ? "Close chat" : "Ask Nora"; 326 if (open) input.focus(); 327 } 328 329 function scrollToBottom() { 330 messagesEl.scrollTop = messagesEl.scrollHeight; 331 } 332 333 function appendMessage(role, text) { 334 const div = document.createElement("div"); 335 div.className = `nora-msg ${role}`; 336 div.textContent = text; 337 messagesEl.appendChild(div); 338 scrollToBottom(); 339 return div; 340 } 341 342 function appendTyping() { 343 const div = document.createElement("div"); 344 div.className = "nora-msg assistant nora-typing"; 345 div.setAttribute("aria-label", "Nora is typing"); 346 div.innerHTML = "<span></span><span></span><span></span>"; 347 messagesEl.appendChild(div); 348 scrollToBottom(); 349 return div; 350 } 351 352 async function send(text) { 353 const trimmed = text.trim(); 354 if (!trimmed || loading) return; 355 356 suggestEl.classList.add("is-hidden"); 357 appendMessage("user", trimmed); 358 history.push({ role: "user", content: trimmed }); 359 input.value = ""; 360 sendBtn.disabled = true; 361 loading = true; 362 363 const typingEl = appendTyping(); 364 365 try { 366 const res = await fetch("/api/nora", { 367 method: "POST", 368 headers: { "Content-Type": "application/json" }, 369 body: JSON.stringify({ messages: history.slice(-MAX_HISTORY) }), 370 }); 371 372 if (!res.ok || !res.body) throw new Error(`Request failed (${res.status})`); 373 374 typingEl.remove(); 375 const assistantEl = appendMessage("assistant", ""); 376 377 const reader = res.body.getReader(); 378 const decoder = new TextDecoder(); 379 let full = ""; 380 381 for (;;) { 382 const { done, value } = await reader.read(); 383 if (done) break; 384 full += decoder.decode(value, { stream: true }); 385 assistantEl.textContent = full; 386 scrollToBottom(); 387 } 388 389 history.push({ role: "assistant", content: full }); 390 } catch (err) { 391 typingEl.remove(); 392 appendMessage("error", "Nora's having trouble connecting right now. Please try again in a moment, or use the contact form below."); 393 console.error("Nora request failed", err); 394 } finally { 395 loading = false; 396 sendBtn.disabled = !input.value.trim(); 397 } 398 } 399 400 launcher.addEventListener("click", () => setOpen(panel.hidden)); 401 closeBtn.addEventListener("click", () => setOpen(false)); 402 input.addEventListener("input", () => { 403 sendBtn.disabled = loading || !input.value.trim(); 404 }); 405 form.addEventListener("submit", (e) => { 406 e.preventDefault(); 407 send(input.value); 408 }); 409 suggestEl.querySelectorAll("button").forEach((btn) => { 410 btn.addEventListener("click", () => send(btn.textContent)); 411 }); 412})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.