PageSourceSearch

https://www.wsinsights.com/script.js

js wsinsights.com collected 2026-10-06 13:42:04 UTC 14,940 bytes, 412 lines download raw bytes

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.