1/* Sonovate 2026 template layer, homepage interactions. 2 Only runs on pages rendered by the 2026 layout ([data-snv-2026]). */ 3(function () { 4 'use strict'; 5 if (!document.querySelector('[data-snv-2026]')) return; 6 7 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 8 9 /* ---------- 1. Nav shell scroll state ---------- */ 10 const navShell = document.querySelector('.nav-shell'); 11 const onScroll = () => { 12 if (!navShell) return; 13 if (window.scrollY > 12) navShell.classList.add('is-scrolled'); 14 else navShell.classList.remove('is-scrolled'); 15 }; 16 window.addEventListener('scroll', onScroll, { passive: true }); 17 onScroll(); 18 19 /* ---------- 2. Nav dropdowns, mega + regular panels ---------- */ 20 21 // Collect all triggers + panels 22 const allTriggers = [ 23 ...document.querySelectorAll('[data-mega-trigger]'), 24 ...document.querySelectorAll('[data-dropdown-trigger]'), 25 ]; 26 const allPanels = [ 27 ...document.querySelectorAll('[data-mega-panel]'), 28 ...document.querySelectorAll('[data-dropdown-panel]'), 29 ]; 30 31 let openTimeout; 32 let closeTimeout; 33 34 function closeAll(exceptPanel) { 35 allPanels.forEach((p) => { 36 if (p === exceptPanel) return; 37 p.classList.remove('is-open'); 38 }); 39 allTriggers.forEach((t) => { 40 const panelId = t.getAttribute('aria-controls'); 41 const controlled = document.getElementById(panelId); 42 if (controlled !== exceptPanel) t.setAttribute('aria-expanded', 'false'); 43 }); 44 } 45 46 function openPanel(trigger, panel) { 47 clearTimeout(closeTimeout); 48 closeAll(panel); 49 // For simple dropdowns: position under the trigger 50 if (panel.classList.contains('dropdown-panel')) { 51 const rect = trigger.getBoundingClientRect(); 52 let left = rect.left; 53 // Clamp so it doesn't overflow viewport 54 const panelW = 300; 55 const maxLeft = window.innerWidth - panelW - 16; 56 left = Math.min(left, maxLeft); 57 panel.style.left = left + 'px'; 58 } 59 panel.classList.add('is-open'); 60 trigger.setAttribute('aria-expanded', 'true'); 61 } 62 63 function closePanel(trigger, panel) { 64 closeTimeout = setTimeout(() => { 65 panel.classList.remove('is-open'); 66 trigger.setAttribute('aria-expanded', 'false'); 67 }, 120); 68 } 69 70 allTriggers.forEach((trigger) => { 71 const panelId = trigger.getAttribute('aria-controls'); 72 const panel = document.getElementById(panelId); 73 if (!panel) return; 74 75 [trigger, panel].forEach((el) => { 76 el.addEventListener('mouseenter', () =>
76 openPanel(trigger, panel)); 77 el.addEventListener('mouseleave', () => closePanel(trigger, panel)); 78 }); 79 trigger.addEventListener('focus', () => openPanel(trigger, panel)); 80 trigger.addEventListener('click', (e) => { 81 e.preventDefault(); 82 // Hover and focus may already have opened it before the click. 83 openPanel(trigger, panel); 84 }); 85 }); 86 87 document.addEventListener('keydown', (e) => { 88 if (e.key === 'Escape') closeAll(null); 89 }); 90 91 // Close when clicking outside 92 document.addEventListener('click', (e) => { 93 if (!e.target.closest('[data-mega-trigger], [data-dropdown-trigger], [data-mega-panel], [data-dropdown-panel]')) { 94 closeAll(null); 95 } 96 }); 97 98 /* ---------- 3. Mobile drawer ---------- */ 99 const mobileToggle = document.querySelector('[data-mobile-toggle]'); 100 const mobileDrawer = document.querySelector('[data-mobile-drawer]'); 101 102 if (mobileToggle && mobileDrawer) { 103 mobileToggle.addEventListener('click', () => { 104 const open = mobileDrawer.classList.toggle('is-open'); 105 mobileToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); 106 document.body.style.overflow = open ? 'hidden' : ''; 107 }); 108 function closeMobileDrawer() { 109 mobileDrawer.classList.remove('is-open'); 110 mobileToggle.setAttribute('aria-expanded', 'false'); 111 document.body.style.overflow = ''; 112 } 113 document.addEventListener('keydown', (event) => { 114 if (event.key === 'Escape' && mobileDrawer.classList.contains('is-open')) { 115 closeMobileDrawer(); 116 mobileToggle.focus(); 117 } 118 }); 119 mobileDrawer.addEventListener('click', (event) => { 120 if (event.target.closest('a')) closeMobileDrawer(); 121 }); 122 window.addEventListener('resize', () => { 123 if (window.innerWidth >= 1024) closeMobileDrawer(); 124 }); 125 } 126 127 /* ---------- 4. Pillar tab switcher ---------- */ 128 const pillarTabs = Array.from(document.querySelectorAll('[data-pillar-tab]')); 129 const pillarPanels = document.querySelectorAll('[data-pillar-panel]'); 130 const pillarTabList = document.querySelector('.pillar-tabs[role="tablist"]'); 131 const pillarVerticalQuery = window.matchMedia('(min-width: 1024px)'); 132 133 function scrollTabInline(tab) { 134 const scroller = tab && tab.parentElement; 135 if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return; 136 137 const targetLeft = tab.offsetLeft - ((scroller.clientWidth - tab.offsetWidth) / 2); 138 scroller.scrollTo({ 139 left: Math.max(0, targetLeft), 140 behavior: prefersReducedMotion ? 'auto' : 'smooth', 141 }); 142 } 143 144 function setPillarOrientation() { 145 if (!pillarTabList) return; 146 pillarTabList.setAttribute('aria-orientation', pillarVerticalQuery.matches ? 'vertical' : 'horizontal'); 147 } 148 setPillarOrientation(); 149 if (pillarVerticalQuery.addEventListener) { 150 pillarVerticalQuery.addEventListener('change', setPillarOrientation); 151 } 152 153 // No auto-rotation: this section teaches vocabulary, so the visitor 154 // controls the pace (WCAG 2.2.2) rather than content moving on its own. 155 function activatePillar(key, options = {}) { 156 pillarTabs.forEach((tab) => { 157 const isMatch = tab.dataset.pillarTab === key; 158 tab.setAttribute('aria-selected', isMatch ? 'true' : 'false'); 159 tab.setAttribute('tabindex', isMatch ? '0' : '-1'); 160 if (isMatch && options.scrollTab) { 161 scrollTabInline(tab); 162 } 163 if (isMatch && options.focusTab) { 164 tab.focus(); 165 } 166 }); 167 pillarPanels.forEach((panel) => { 168 const isMatch = panel.dataset.pillarPanel === key; 169 panel.classList.toggle('is-active', isMatch); 170 }); 171 } 172 173 pillarTabs.forEach((tab) => { 174 tab.addEventListener('click', () => { 175 activatePillar(tab.dataset.pillarTab, { scrollTab: true }); 176 }); 177 tab.addEventListener('keydown', (e) => { 178 const isVertical = pillarVerticalQuery.matches; 179 const isNextKey = e.key === 'ArrowRight' || (isVertical && e.key === 'ArrowDown'); 180 const isPrevKey = e.key === 'ArrowLeft' || (isVertical && e.key === 'ArrowUp'); 181 182 if (isNextKey) { 183 e.preventDefault(); 184 const next = tab.nextElementSibling || pillarTabs[0]; 185 activatePillar(next.dataset.pillarTab, { scrollTab: true, focusTab: true }); 186 } else if (isPrevKey) { 187 e.preventDefault(); 188 const prev = tab.previousElementSibling || pillarTabs[pillarTabs.length - 1]; 189 activatePillar(prev.dataset.pillarTab, { scrollTab: true, focusTab: true }); 190 } else if (e.key === 'Home') { 191 e.preventDefault(); 192 activatePillar(pillarTabs[0].dataset.pillarTab, { scrollTab: true, focusTab: true }); 193 } else if (e.key === 'End') { 194 e.preventDefault(); 195 activatePillar(pillarTabs[pillarTabs.length - 1].dataset.pillarTab, { scrollTab: true, focusTab: true }); 196 } 197 }); 198 }); 199 200 /* ---------- 5. Buyer tab switcher ---------- */ 201 const buyerSection = document.getElementById('buyers'); 202 const buyerTabs = Array.from(document.querySelectorAll('[data-buyer-tab]')); 203 const buyerContents = document.querySelectorAll('[data-buyer-content]'); 204 let buyerAutoTimer = null; 205 let buyerAutoStarted = false;
206 let buyerAutoStopped = false; 207 208 function activateBuyer(key, options = {}) { 209 buyerTabs.forEach((tab) => { 210 const isMatch = tab.dataset.buyerTab === key; 211 tab.setAttribute('aria-selected', isMatch ? 'true' : 'false'); 212 if (isMatch && options.scrollTab) { 213 scrollTabInline(tab); 214 } 215 }); 216 buyerContents.forEach((content) => { 217 const isMatch = content.dataset.buyerContent === key; 218 content.classList.toggle('is-active', isMatch); 219 }); 220 } 221 222 function stopBuyerAutoCycle() { 223 buyerAutoStopped = true; 224 if (buyerAutoTimer) { 225 clearInterval(buyerAutoTimer); 226 buyerAutoTimer = null; 227 } 228 } 229 230 function startBuyerAutoCycle() { 231 if (!buyerTabs.length || buyerAutoStarted || buyerAutoStopped || prefersReducedMotion) return; 232 233 buyerAutoStarted = true; 234 let activeIndex = 0; 235 activateBuyer(buyerTabs[activeIndex].dataset.buyerTab, { scrollTab: true }); 236 237 buyerAutoTimer = setInterval(() => { 238 if (document.hidden || buyerAutoStopped) return; 239 activeIndex = (activeIndex + 1) % buyerTabs.length; 240 activateBuyer(buyerTabs[activeIndex].dataset.buyerTab, { scrollTab: true }); 241 }, 3600); 242 } 243 244 buyerTabs.forEach((tab) => { 245 tab.addEventListener('click', () => { 246 stopBuyerAutoCycle(); 247 activateBuyer(tab.dataset.buyerTab, { scrollTab: true }); 248 }); 249 tab.addEventListener('keydown', (e) => { 250 if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { 251 e.preventDefault(); 252 stopBuyerAutoCycle(); 253 const next = tab.nextElementSibling || buyerTabs[0]; 254 next.focus(); 255 activateBuyer(next.dataset.buyerTab, { scrollTab: true }); 256 } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { 257 e.preventDefault(); 258 stopBuyerAutoCycle(); 259 const prev = tab.previousElementSibling || buyerTabs[buyerTabs.length - 1]; 260 prev.focus(); 261 activateBuyer(prev.dataset.buyerTab, { scrollTab: true }); 262 } 263 }); 264 }); 265 266 if (buyerSection && buyerTabs.length) { 267 if ('IntersectionObserver' in window) { 268 const buyerObserver = new IntersectionObserver((entries) => { 269 entries.forEach((entry) => { 270 if (entry.isIntersecting) { 271 startBuyerAutoCycle(); 272 buyerObserver.unobserve(entry.target); 273 } 274 }); 275 }, { threshold: 0.35 }); 276 buyerObserver.observe(buyerSection); 277 } else { 278 startBuyerAutoCycle(); 279 } 280 } 281 282 /* ---------- 6. Scroll reveal ---------- */ 283 if ('IntersectionObserver' in window && !prefersReducedMotion) { 284 const reveals = document.querySelectorAll('.reveal'); 285 const io = new IntersectionObserver( 286 (entries) => { 287 entries.forEach((entry) => { 288 if (entry.isIntersecting) { 289 entry.target.classList.add('is-visible'); 290 io.unobserve(entry.target); 291 } 292 }); 293 }, 294 { rootMargin: '-40px 0px -40px 0px', threshold: 0.04 } 295 ); 296 reveals.forEach((el) => io.observe(el)); 297 } else { 298 document.querySelectorAll('.reveal').forEach((el) => el.classList.add('is-visible')); 299 } 300 301 /* ---------- 7. Animated stat counters ---------- */ 302 function animateCount(el, target, suffix = '', duration = 1600) { 303 // Keep the approved server-rendered value when animation cannot run. 304 const finalText = el.hasAttribute('data-prefix') ? el.textContent : formatNum(target) + suffix; 305 if (!Number.isFinite(target)) return; 306 if (prefersReducedMotion) { el.textContent = finalText; return; } 307 const prefix = el.dataset.prefix || ''; 308 const decimals = (String(target).split('.')[1] || '').length; 309 const start = performance.now(); 310 function tick(now) { 311 const t = Math.min((now - start) / duration, 1); 312 const eased = 1 - Math.pow(1 - t, 4); 313 const v = Number((target * eased).toFixed(decimals)); 314 el.textContent = t === 1 ? finalText : prefix + formatNum(v) + suffix; 315 if (t < 1) requestAnimationFrame(tick); 316 } 317 requestAnimationFrame(tick); 318 } 319 function formatNum(n) { 320 return n.toLocaleString('en-GB'); 321 } 322 323 if ('IntersectionObserver' in window) { 324 const counters = document.querySelectorAll('[data-counter]'); 325 const countObserver = new IntersectionObserver( 326 (entries) => {
327 entries.forEach((entry) => { 328 if (entry.isIntersecting) { 329 const el = entry.target; 330 animateCount(el, Number(el.dataset.counter), el.dataset.suffix || ''); 331 countObserver.unobserve(el); 332 } 333 }); 334 }, 335 { threshold: 0.4 } 336 ); 337 counters.forEach((c) => countObserver.observe(c)); 338 } 339 340 /* ---------- 8. Hero run-step cycling ---------- */ 341 const runSteps = Array.from(document.querySelectorAll('[data-run-step]')); 342 if (runSteps.length) { 343 const STEP_DURATION = 2400; // ms each step stays active 344 // Start on a random step 345 let activeIndex = Math.floor(Math.random() * runSteps.length); 346 347 function applyStepStates(active) { 348 runSteps.forEach((step, i) => { 349 step.classList.remove('run-step--done', 'run-step--active', 'run-step--pending'); 350 if (i < active) step.classList.add('run-step--done'); 351 else if (i === active) step.classList.add('run-step--active'); 352 else step.classList.add('run-step--pending'); 353 }); 354 } 355 356 // Wait for the initial step-in animations to finish before taking over 357 const initDelay = prefersReducedMotion ? 0 : 3000; 358 setTimeout(() => { 359 applyStepStates(activeIndex); 360 setInterval(() => { 361 activeIndex = (activeIndex + 1) % runSteps.length; 362 applyStepStates(activeIndex); 363 }, STEP_DURATION); 364 }, initDelay); 365 } 366 367 /* ---------- 9. Hero peek subtle parallax ---------- */ 368 const peek = document.querySelector('[data-peek]'); 369 if (peek && !prefersReducedMotion && window.matchMedia('(pointer: fine)').matches) { 370 const parent = peek.parentElement; 371 parent.addEventListener('mousemove', (e) => { 372 const rect = parent.getBoundingClientRect(); 373 const x = (e.clientX - rect.left) / rect.width - 0.5; 374 const y = (e.clientY - rect.top) / rect.height - 0.5; 375 peek.style.transform = `perspective(1200px) rotateY(${x * 3}deg) rotateX(${y * -3}deg)`; 376 }); 377 parent.addEventListener('mouseleave', () => { 378 peek.style.transform = ''; 379 }); 380 } 381 382/* ââ Ask Sonovate: guided answers ââ 383 Guided-first by design. The chips are the primary interface and every answer 384 offers follow-ups, so a visitor can drill down without ever needing to guess 385 what the widget knows. Answers are instant: no fake typing, because we are 386 not pretending to think. 387 Free text still works. When it matches a topic you get the answer; when it 388 does not, the fallback captures the question and an email instead of 389 apologising, and every question asked is logged so we learn what to write 390 next. */ 391(function () { 392 'use strict'; 393 394 var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 395 396 var SNV = Object.assign({ 397 funded: '\u00A38bn+', payments: '800,000+', customers: '3,300+', 398 countries: '44', backer: 'Backed by DZ Bank' 399 }, (typeof SNV_PROOF !== 'undefined' ? SNV_PROOF : {})); 400 401 var TOPICS = { 402 fund: { 403 q: "How does invoice funding work?", 404 next: ["speed", "pricing", "backing"], 405 a: `Sonovate's invoice funding works differently to a traditional facility. When you raise an invoice for a placed worker, capital is available the same day. You do not wait for your client to settle.\n\nThe capital sits on an institutional balance sheet backed by DZ Bank, so it grows with your book rather than being reviewed when market conditions change. We cover contract and temp placements through invoice funding, permanent placements through permanent funding, and all placement types through total funding.` 406 }, 407 speed: { 408 q: "How quickly are invoices funded?", 409 next: ["fund", "pay", "start"], 410 a: `Invoices are typically available for funding the same day they're raised, there's no waiting for a credit line to be reviewed or a facility to be refreshed. The capital is on our balance sheet and moves as your invoices move.\n\nOnce funded, worker payments queue automatically and clear on the next payment run.` 411 }, 412 pricing: { 413 q: "How does pricing work?", 414 next: ["fund", "start", "platform"], 415 a: `Sonovate doesn't publish pricing publicly. It's configured around each customer's model, placement types and volume, there are no standard tiers or per-seat fees.\
415n\nThe commercial conversation starts with understanding what you're funding and how your business is structured. Our team can give you a realistic picture for your book specifically. The best next step is a quick conversation, sonovate.com/contact.` 416 }, 417 backing: { 418 q: "Who backs your capital?", 419 next: ["fund", "protect", "platform"], 420 a: `Sonovate's capital is backed by DZ Bank, Germany's second largest bank and one of the world's highest-rated financial institutions, alongside other institutional partners.\n\nThis matters because it means the funding line is built on a stable balance sheet, not a lender who adjusts terms when credit conditions tighten. The capital is designed to grow with your book, not be reviewed against it.` 421 }, 422 pay: { 423 q: "How do worker payments work?", 424 next: ["operate", "countries", "speed"], 425 a: `The Pay pillar handles payroll, payments and transactions for the workers you place. Once an invoice is funded, worker payments queue automatically, with no manual reconciliation, no separate payroll system.\n\nPay supports umbrella, PAYE, limited company and self-employed models across multiple currencies and ${SNV.countries} countries.` 426 }, 427 operate: { 428 q: "What does the back office cover?", 429 next: ["pay", "skills", "api"], 430 a: `Operate is Sonovate's back office layer: timesheets, invoicing, assignments, and the workflow that connects them. It captures approved time, generates invoices automatically, and feeds them into Fund without manual intervention.\n\nMost customers find this eliminates significant back-office admin and frees their team to focus on placements rather than paperwork.` 431 }, 432 protect: { 433 q: "How do you handle risk?", 434 next: ["fund", "backing", "operate"], 435 a: `Protect covers credit, compliance, risk monitoring and audit controls. It includes real-time debtor credit monitoring, IR35 compliance checks, right-to-work verification, and a full audit trail on every transaction.\n\nCritically, Protect runs inside the same platform as Fund and Pay, so risk assessment happens before capital is committed, not as a separate process.` 436 }, 437 scale: { 438 q: "Can you support us abroad?", 439 next: ["countries", "pay", "api"], 440 a: `Scale supports international expansion, new markets, currencies, local compliance and non-standard payment models. Sonovate currently operates in ${SNV.countries} countries, so if you're placing workers across borders or planning to move into new markets, the infrastructure is already there.` 441 }, 442 countries: { 443 q: "What countries do you support?", 444 next: ["scale", "pay", "start"], 445 a: `Sonovate currently supports operations in ${SNV.countries} countries. This covers funding, payments, and compliance requirements across most major markets where UK and European staffing and recruitment businesses operate.\n\nIf you have specific countries in mind, our team can confirm coverage and any local requirements. Reach us at sonovate.com/contact.` 446 }, 447 skills: { 448 q: "What is the Skills Marketplace?", 449 next: ["operate", "platform", "start"], 450 a: `The Skills Marketplace is Sonovate's automation layer, configurable automations that run inside Fund, Pay, Operate and Protect, triggered by events that matter in your workflow.\n\nExamples: automatically fund an invoice when it's raised, flag a debtor when their credit rating drops, queue a worker payment the moment funding clears. Skills reduce manual touches without requiring custom development.` 451 }, 452 api: { 453 q: "Do you have an API?", 454 next: ["operate", "skills", "start"], 455 a: `Sonovate has an API-first integration layer. The platform connects to Microsoft's ecosystem, Codat for accounting data, Xero, Sage, QuickBooks, and major ATS systems including Bullhorn.\n\nFor platforms wanting to offer Sonovate's funding and payment capabilities inside their own product, there's also an embedded finance API, your brand, your experience, our balance sheet.` 456 }, 457 platform: { 458 q: "What does Sonovate actually do?", 459 next: ["fund", "pay", "skills"], 460 a: `Sonovate is a financial platform for workforce businesses, recruitment agencies, staffing platforms and consultancies. It covers five connected capabilities:\n\n· Fund, invoice and business funding backed by institutional capital\n· Pay, payroll and payments for placed workers\n· Operate, timesheets, invoicing and back office workflow\n· Protect, credit, compliance and risk monitoring\n· Scale, international expansion and growth infrastru
460cture\n\nMost customers start with funding and stay because the platform does so much more.` 461 }, 462 agency: { 463 q: "Is this built for agencies?", 464 next: ["fund", "operate", "start"], 465 a: `Sonovate was built for recruitment agencies from day one. The model was originally designed around the cash flow challenge agencies know well, placing workers this week, waiting 60â90 days for invoices to settle.\n\nToday the platform supports agencies at every stage: start-ups placing their first contractors, growing agencies building infrastructure, and high-growth firms with complex international operations and multi-million pound funding facilities.` 466 }, 467 start: { 468 q: "How do we get started?", 469 next: ["pricing", "platform"], 470 a: `The best starting point is a conversation with the sales team. They'll ask about your placement model, the types of workers you place, your current setup, and what's prompting you to look now.\n\nFrom that they'll show you a platform configuration built around your business, not a generic demo. You can reach them at sonovate.com/contact or by clicking the Sign up button above.` 471 }, 472 }; 473 474 var START = ["platform", "fund", "speed", "pricing", "countries", "skills"]; 475 476 var MATCH = [ 477 ['speed', /how.?quick|how.?fast|same.?day|how.?soon|when.?fund|how.?long/], 478 ['pricing', /pric|cost|how.?much|fee|rate|charge|tier|package|quote/], 479 ['backing', /who back|dz.?bank|institutional|investor|balance.?sheet|funded by/], 480 ['countries', /countr|abroad|overseas|international|global|outside the uk|europe|\bus\b/], 481 ['skills', /skill|automat|digital worker|marketplace|outcome/], 482 ['api', /api|integrat|developer|codat|xero|sage|bullhorn|embed|connect/], 483 ['protect', /protect|compli|risk|credit|audit|ir35|right.?to.?work|bad debt|default/], 484 ['operate', /operat|timesheet|back.?offic|admin|assignment|invoicing|reconcil/], 485 ['pay', /payroll|payment|\bpay\b|wage|salary|paye|umbrella|worker.?paid/], 486 ['scale', /scale|expan|new market|currenc|grow/], 487 ['fund', /invoice|fund|capital|facilit|borrow|advance|cash.?flow/], 488 ['agency', /agenc|recruit|staffing|consultan/], 489 ['start', /start|sign.?up|demo|get.?going|onboard|next step|talk to/], 490 ['platform', /what.?is|what.?do|platform|overview|explain|tell.?me.?about/] 491 ]; 492 493 /* Does the input look like a real question? Gibberish and stray keystrokes 494 should not be met with a lead-capture form. */ 495 function looksLikeQuestion(q) { 496 var t = String(q).trim(); 497 if (t.length < 8) return false; 498 if (!/[aeiou]/i.test(t)) return false; // keyboard mash 499 var words = t.split(/\s+/).filter(function (w) { return w.length > 1; }); 500 return words.length >= 2 || /\?$/.test(t); 501 } 502 503 /* A gentle nudge back into the guided flow, with no email ask. */ 504 function nudge() { 505 respond(function () { 506 var b = bubble('ai'); 507 revealParagraphs(b, 'I did not catch that. Try one of these, or ask a full question and I will do my best.'); 508 followUps(b, START.slice(0, 4), 'Try one of these'); 509 }); 510 } 511 512 function matchTopic(q) { 513 var t = q.toLowerCase(); 514 for (var i = 0; i < MATCH.length; i++) { 515 if (MATCH[i][1].test(t)) return MATCH[i][0]; 516 } 517 return null; 518 } 519 520 /* Log every question so we learn what prospects actually ask. */ 521 function track(question, topic) { 522 try { 523 window.dataLayer = window.dataLayer || []; 524 window.dataLayer.push({ 525 event: 'ask_sonovate', 526 ask_question: String(question).slice(0, 120), 527 ask_topic: topic || 'unmatched', 528 ask_matched: topic ? 'yes' : 'no' 529 }); 530 } catch (e) {} 531 } 532 533 var msgs = document.getElementById('ask-messages'); 534 var input = document.getElementById('ask-input'); 535 var sendBtn = document.getElementById('ask-send'); 536 var chipRow = document.querySelector('.ask-chips'); 537 if (!msgs) return; 538 539 var AI_AVATAR = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="22" height="22" aria-hidden="true"><path d="M22 11a4 4 0 0 0-4-4h-4a4 4 0 0 0 0 8h4a4 4 0 0 1 0 8h-4a4 4 0 0 1-4-4" stroke="#01E1FD" stroke-width="2.5" stroke-linecap="round" fill="none"/></svg>'; 540 var USER_AVATAR = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'; 541 542 /* A short beat before answering. Not fake composition: the answers are 543 pre-written and we say so. This is pacing, so the reader registers that 544 something happened and has a moment to move from clicking to reading. */ 545 function thinking() { 546 var wrap = document.createElement('div'); 547 wrap.className = 'ask-msg ask-msg--ai'; 548 var av = document.createElement('div'); 549 av.className = 'ask-avatar'; 550 av.innerHTML = AI_AVATAR; 551 var b = document.createElement('div'); 552 b.className = 'ask-bubble ask-typing'; 553 b.innerHTML = '<span></span><span></span><span></span>'; 554 b.setAttribute('aria-label', 'Finding an answer'); 555 wrap.appendChild(av); 556 wrap.appendChild(b); 557 msgs.appendChild(wrap); 558 msgs.scrollTop = msgs.scrollHeight; 559 return wrap; 560 } 561 562 /* Reveal paragraphs with a light stagger, so a long answer lands in 563 readable beats rather than as one wall of text. */ 564 function revealParagraphs(el, text) { 565 var parts = String(text).split('\n\n');
566 parts.forEach(function (para, i) { 567 var p = document.createElement('p'); 568 p.textContent = para; 569 if (!reduceMotion) { 570 p.className = 'ask-reveal'; 571 p.style.animationDelay = (i * 70) + 'ms'; 572 } 573 el.appendChild(p); 574 }); 575 } 576 577 /* Answer after a pause, with slight variance so it does not feel robotic. */ 578 function respond(build) { 579 if (reduceMotion) { build(); return; } 580 var dots = thinking(); 581 var wait = 420 + Math.random() * 260; 582 setTimeout(function () { 583 dots.remove(); 584 build(); 585 }, wait); 586 } 587 588 function bubble(role) { 589 var wrap = document.createElement('div'); 590 wrap.className = 'ask-msg ask-msg--' + role; 591 var av = document.createElement('div'); 592 av.className = 'ask-avatar'; 593 av.innerHTML = role === 'ai' ? AI_AVATAR : USER_AVATAR; 594 var b = document.createElement('div'); 595 b.className = 'ask-bubble'; 596 wrap.appendChild(av); 597 wrap.appendChild(b); 598 msgs.appendChild(wrap); 599 msgs.scrollTop = msgs.scrollHeight; 600 return b; 601 } 602 603 function paragraphs(el, text) { 604 String(text).split('\n\n').forEach(function (para) { 605 var p = document.createElement('p'); 606 p.textContent = para; 607 el.appendChild(p); 608 }); 609 } 610 611 /* Follow-up chips under an answer: the drill-down path. */ 612 function followUps(after, keys, label) { 613 if (!keys || !keys.length) return; 614 var row = document.createElement('div'); 615 row.className = 'ask-followups'; 616 if (label) { 617 var lb = document.createElement('span'); 618 lb.className = 'ask-followups-label'; 619 lb.textContent = label; 620 row.appendChild(lb); 621 } 622 keys.forEach(function (k) { 623 if (!TOPICS[k]) return; 624 var b = document.createElement('button'); 625 b.type = 'button'; 626 b.className = 'ask-chip ask-chip--followup'; 627 b.textContent = TOPICS[k].q; 628 b.addEventListener('click', function () { ask(TOPICS[k].q, k); }); 629 row.appendChild(b); 630 }); 631 after.appendChild(row); 632 msgs.scrollTop = msgs.scrollHeight; 633 } 634 635 /* Unmatched: capture the question and an email rather than apologise. */ 636 function captureForm(question) { 637 var b = bubble('ai'); 638 revealParagraphs(b, 'We do not have an answer to that here yet. Tell our team what you need and we will help you find the right information.'); 639 640 var form = document.createElement('form'); 641 form.className = 'ask-capture'; 642 form.innerHTML = 643 '<label class="sr-only" for="ask-capture-email">Your work email</label>' + 644 '<input class="ask-capture-input" type="email" id="ask-capture-email" name="email" placeholder="Your work email" autocomplete="email" required>' + 645 '<button class="ask-capture-send" type="submit">Send my question</button>'; 646 form.addEventListener('submit', function (e) { 647 e.preventDefault(); 648 var email = form.querySelector('input').value.trim(); 649 if (!email) return; 650 651 var btn = form.querySelector('.ask-capture-send'); 652 if (btn) { btn.disabled = true; btn.textContent = 'Sendingâ¦'; } 653 654 try { 655 window.dataLayer = window.dataLayer || []; 656 window.dataLayer.push({ 657 event: 'ask_sonovate_capture', 658 ask_question: String(question).slice(0, 120), 659 ask_email: email 660 }); 661 } catch (err) {} 662 663 function done() { 664 form.innerHTML = '<p class="ask-capture-done">
664Thanks. We have received your question and our team will come back to you.</p>'; 665 } 666 667 var hs = (typeof SNV_HS !== 'undefined') ? SNV_HS : null; 668 if (!hs || !hs.formId) { done(); return; } 669 670 /* hubspotutk links this submission to the visitor's existing history 671 in HubSpot instead of creating an unattributed contact. */ 672 var hutk = (document.cookie.match(/hubspotutk=([^;]+)/) || [])[1] || ''; 673 var payload = { 674 fields: [ 675 { objectTypeId: '0-1', name: 'email', value: email }, 676 { objectTypeId: '0-1', name: hs.questionField || 'message', value: String(question).slice(0, 900) } 677 ], 678 context: { 679 pageUri: window.location.href, 680 pageName: document.title 681 } 682 }; 683 if (hutk) payload.context.hutk = hutk; 684 685 fetch('https://api.hsforms.com/submissions/v3/integration/submit/' + hs.portalId + '/' + hs.formId, { 686 method: 'POST', 687 headers: { 'Content-Type': 'application/json' }, 688 body: JSON.stringify(payload) 689 }).then(function (res) { 690 if (!res.ok && window.console) console.warn('Ask Sonovate: HubSpot rejected the submission', res.status); 691 done(); 692 }).catch(function () { 693 /* Never strand the visitor on a network error: the question is 694 already in the dataLayer, so confirm and move on. */ 695 done(); 696 }); 697 }); 698 b.appendChild(form); 699 followUps(b, START.slice(0, 3), 'Or pick a question'); 700 msgs.scrollTop = msgs.scrollHeight; 701 } 702 703 function ask(question, topicKey) { 704 var q = String(question).trim(); 705 if (!q) return; 706 input.value = ''; 707 708 var userBubble = bubble('user'); 709 var p = document.createElement('p'); 710 p.textContent = q; 711 userBubble.appendChild(p); 712 713 var key = topicKey || matchTopic(q); 714 track(q, key); 715 716 if (!key || !TOPICS[key]) { 717 if (looksLikeQuestion(q)) respond(function () { captureForm(q); }); 718 else nudge(); 719 return; 720 } 721 722 respond(function () { 723 var b = bubble('ai'); 724 revealParagraphs(b, TOPICS[key].a); 725 followUps(b, TOPICS[key].next); 726 }); 727 } 728 729 /* Starter chips attach to the opening message, so the questions sit directly 730 under the invitation to ask one. Same pattern as follow-ups beneath every 731 answer: the reader learns it once. */ 732 var greeting = msgs.querySelector('.ask-msg--ai .ask-bubble'); 733 if (greeting) { 734 followUps(greeting, START, 'Popular questions'); 735 } 736 if (chipRow) chipRow.remove(); 737 738 if (sendBtn) sendBtn.addEventListener('click', function () { ask(input.value); }); 739 if (input) { 740 input.addEventListener('keydown', function (e) { 741 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(input.value); } 742 }); 743 } 744})(); 745 746 /* ---------- 10. Skills Marketplace analytics ---------- */ 747 (function () { 748 const SECTION = 'skills_marketplace'; 749 const skillsSection = document.getElementById('skills'); 750 if (!skillsSection) return; 751 752 // Event names stay generic and reusable across the site (GA4 already 753 // attaches page_location/page_title to every event, and keeps its own 754 // event list smaller when sections share names). Which section and 755 // what happened live in the parameters instead, e.g. 756 // section_view {section: 'skills_marketplace'}, 757 // cta_click {section: 'skills_marketplace', cta_location: 'hero'}. 758 function track(eventName, params) { 759 if (typeof gtag === 'function') gtag('event', eventName, Object.assign({ section: SECTION }, params)); 760 } 761 762 // Fires once per pageview when the section is meaningfully on screen, 763 // not just clipping the viewport edge. 764 if ('IntersectionObserver' in window) { 765 const sectionObserver = new IntersectionObserver( 766 (entries) => { 767 entries.forEach((entry) => { 768 if (entry.isIntersecting) { 769 track('section_view'); 770 sectionObserver.unobserve(entry.target); 771 } 772 }); 773 }, 774 { threshold: 0.5 } 775 ); 776 sectionObserver.observe(skillsSection); 777 } 778 779 // The marquee renders every tile twice (a duplicate, aria-hidden pass) 780 // for a seamless loop. Report the logical position within one set of 781 // tiles regardless of which copy was actually clicked. 782 function tilePosition(article) { 783 const tiles = Array.from(skillsSection.querySelectorAll('.skills-tile')); 784 const setSize = tiles.length / 2; 785 const index = tiles.indexOf(article); 786 return setSize > 0 ? index % setSize : index; 787 } 788 789 skillsSection.addEventListener('click', (event) => { 790 const heroCta = event.target.closest('.skills-section-actions a'); 791 if (heroCta) { 792 track('cta_click', { cta_location: 'hero' }); 793 return; 794 } 795 796 const bannerCta = event.target.closest('.skills-cta-banner'); 797 if (bannerCta) { 798 track('cta_click', { cta_location: 'banner' }); 799 return; 800 } 801 802 const tileLink = event.target.closest('.skills-tile-link'); 803 if (tileLink) { 804 const article = tileLink.closest('.skills-tile');
805 const kind = article && article.classList.contains('skills-tile--worker') ? 'worker' : 'skill'; 806 const title = tileLink.querySelector('h3'); 807 track('content_click', { 808 content_type: kind === 'worker' ? 'digital_worker' : 'skill', 809 content_name: title ? title.textContent.trim() : '', 810 content_position: article ? tilePosition(article) : null, 811 content_location: 'skills_marketplace', 812 }); 813 return; 814 } 815 816 const connector = event.target.closest('.skills-connector'); 817 if (connector) { 818 const title = connector.querySelector('h3'); 819 const badge = connector.querySelector('.skills-connector-badge'); 820 track('content_click', { 821 content_type: 'connector', 822 content_name: title ? title.textContent.trim() : '', 823 content_label: badge ? badge.textContent.trim() : '', 824 content_location: 'skills_marketplace', 825 }); 826 } 827 }); 828 })(); 829 830 /* ---------- 11. Shared navigation analytics ---------- */ 831 (function () { 832 function track(eventName, params) { 833 if (typeof gtag === 'function') gtag('event', eventName, Object.assign({ page_context: 'snv2026' }, params)); 834 } 835 836 document.addEventListener('click', (event) => { 837 const link = event.target.closest('a'); 838 if (!link) return; 839 if (link.closest('.skills-tile-link, .skills-cta-banner, .skills-section-actions')) return; 840 841 const panel = link.closest('[data-mega-panel], [data-dropdown-panel], [data-mobile-drawer]'); 842 const footer = link.closest('.footer'); 843 const header = link.closest('.nav-shell'); 844 const isCta = link.matches('.btn, .btn-primary, .btn-cta, .btn-ghost, .btn-ghost-on-dark'); 845 const location = footer ? 'footer' : panel ? (panel.closest('[data-mobile-drawer]') ? 'mobile_menu' : 'header_menu') : header ? 'header' : 'content'; 846 const group = panel ? (panel.getAttribute('aria-label') || '').replace(/\s+menu$/i, '').trim().toLowerCase().replace(/\s+/g, '_') : ''; 847 const label = (link.textContent || link.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim(); 848 const destination = link.href || ''; 849 850 if (isCta) { 851 track('cta_click', { cta_location: location, cta_text: label, destination }); 852 } else if (location !== 'content' || panel) { 853 track('navigation_click', { nav_location: location, nav_group: group, link_text: label, destination }); 854 } 855 }); 856 })(); 857})(); 858 859/* Highlight the section currently represented in the server-rendered contents. */ 860(function () { 861 var toc = document.getElementById('post-toc'); 862 if (!toc || !('IntersectionObserver' in window)) return; 863 864 var links = Array.prototype.slice.call(toc.querySelectorAll('a[href^="#"]')); 865 var sections = links.map(function (link) { 866 try { return document.getElementById(decodeURIComponent(link.hash.slice(1))); } 867 catch (error) { return null; } 868 }).filter(Boolean); 869 if (!sections.length) return; 870 871 function activate(id) { 872 links.forEach(function (link) { 873 var active = link.hash === '#' + id; 874 link.parentElement.classList.toggle('is-active', active); 875 if (active) link.setAttribute('aria-current', 'location'); 876 else link.removeAttribute('aria-current'); 877 }); 878 } 879 880 var visible = {}; 881 var observer = new IntersectionObserver(function (entries) { 882 entries.forEach(function (entry) { 883 if (entry.isIntersecting) visible[entry.target.id] = entry.boundingClientRect.top; 884 else delete visible[entry.target.id]; 885 }); 886 var ids = Object.keys(visible).sort(function (a, b) { return visible[a] - visible[b]; }); 887 if (ids.length) activate(ids[0]); 888 }, { rootMargin: '-15% 0px -70% 0px' }); 889 890 sections.forEach(function (section) { observer.observe(section); }); 891 activate(sections[0].id); 892})();
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.