1// main.js - theme toggle, mobile nav, smooth-scroll for anchors 2(function () { 3 'use strict'; 4 5 // ---------- Theme toggle ---------- 6 var themeToggle = document.querySelector('[data-theme-toggle]'); 7 if (themeToggle) { 8 themeToggle.addEventListener('click', function () { 9 var current = document.documentElement.getAttribute('data-theme'); 10 var next = current === 'dark' ? 'light' : 'dark'; 11 document.documentElement.setAttribute('data-theme', next); 12 try { localStorage.setItem('tm-theme', next); } catch (e) {} 13 themeToggle.setAttribute('aria-pressed', next === 'dark' ? 'true' : 'false'); 14 }); 15 // Initial aria-pressed sync 16 var current = document.documentElement.getAttribute('data-theme'); 17 themeToggle.setAttribute('aria-pressed', current === 'dark' ? 'true' : 'false'); 18 } 19 20 // ---------- Mobile nav (hamburger overlay) ---------- 21 var hamburger = document.querySelector('[data-nav-toggle]'); 22 var nav = document.querySelector('[data-mobile-nav]'); 23 if (hamburger && nav) { 24 function setNav(open) { 25 nav.classList.toggle('is-open', open); 26 hamburger.setAttribute('aria-expanded', open ? 'true' : 'false'); 27 // .nav-open (on <html>) pins the header to the viewport top AND locks page 28 // scroll (both in CSS) so the hamburger always stays reachable to close the 29 // menu - position:sticky was scrolling out of reach on mobile and trapping 30 // the user behind the open overlay. 31 document.documentElement.classList.toggle('nav-open', open); 32 } 33 hamburger.addEventListener('click', function () { 34 setNav(!nav.classList.contains('is-open')); 35 }); 36 // Close on link click 37 nav.querySelectorAll('a').forEach(function (a) { 38 a.addEventListener('click', function () { setNav(false); }); 39 }); 40 // Close on Escape 41 document.addEventListener('keydown', function (e) { 42 if (e.key === 'Escape' && nav.classList.contains('is-open')) setNav(false); 43 }); 44 } 45 46 // ---------- Contact form (POSTs to rybbit API, MailerSend backend) ---------- 47 // Same handler covers the marketing /contact form AND the embedded docs-page 48 // help form (one per page). data-contact-source carries the docs section/slug 49 // so the email's subject/body tell us which page the user submitted from. 50 // The endpoint is server/src/api/contact-message.ts (registered as 51 // POST /contact-message under the rybbit /api prefix). 52 var CONTACT_API_URL = 'https://app.truemetriks.com/api/contact-message'; 53 var contactForms = document.querySelectorAll('[data-contact-form]'); 54 contactForms.forEach(function (form) { 55 form.addEventListener('submit', function (e) { 56 e.preventDefault(); 57 var get = function (n) { 58 var el = form.querySelector('[name="' + n + '"]'); 59 return el ? el.value.trim() : ''; 60 }; 61 var submitBtn = form.querySelector('.form-submit'); 62 var originalLabel = submitBtn ? submitBtn.textContent : 'Send message'; 63 var statusEl = form.querySelector('[data-contact-status]'); 64 var setStatus = function (kind, msg) { 65 if (!statusEl) return; 66 statusEl.textContent = msg || ''; 67 statusEl.setAttribute('data-status', kind || ''); 68 }; 69 70 // Client-side guard: require a real message. Mirrors the server's 71 // 30-char minimum so users get instant feedback instead of a round-trip. 72 var MIN_MESSAGE_LEN = 30; 73 var messageVal = get('message'); 74 if (messageVal.length < MIN_MESSAGE_LEN) { 75 setStatus('error', 'Please write at least ' + MIN_MESSAGE_LEN + ' characters so we can help you better.'); 76 var msgEl = form.querySelector('[name="message"]'); 77 if (msgEl) msgEl.focus(); 78 return; 79 } 80 81 // Cloudflare Turnstile token (injected by the widget as a hidden input). 82 // If the widget is on the page but not solved yet, block and ask. 83 var turnstileToken = get('cf-turnstile-response'); 84 if (form.querySelector('.cf-turnstile') && !turnstileToken) { 85 setStatus('error', 'Please complete the verification below, then send.'); 86 return; 87 } 88 89 // Marketing form labels its optional field "company"; docs form uses 90 // "website". Normalise client-side so the API only knows "website". 91 var payload = { 92 name: get('name'), 93 email: get('email'), 94 website: get('website') || get('company') || '', 95 message: messageVal, 96 source: form.getAttribute('data-contact-source') || '', 97 companyName_hp: get('companyName_hp') || '', 98 turnstileToken: turnstileToken 99 }; 100 101 if (submitBtn) { 102 submitBtn.disabled = true; 103 submitBtn.textContent = 'Sending...'; 104 } 105 setStatus('', ''); 106 107 fetch(CONTACT_API_URL, { 108 method: 'POST', 109 headers: { 'Content-Type': 'application/json' }, 110 body: JSON.stringify(payload) 111 }) 112 .then(function (res) { 113 return res.json().then(function (data) { return { ok: res.ok, data: data }; }); 114 }) 115 .then(function (r) { 116 if (r.ok && r.data && r.data.ok) { 117 // Swap the whole form for a success card. Replacement is per-form 118 // so multiple forms on the page (unlikely, but possible) are 119 // independent. 120 var success = document.createElement('div'); 121 success.className = 'contact-form-success'; 122 success.setAttribute('role', 'status'); 123 success.innerHTML = 124 '<div class="contact-form-success__icon" aria-hidden="true">' 125 + '<svg width="44" height="44" viewBox="0 0 24 24" fill="none" ' 126 + 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' 127 + '<polyline points="20 6 9 17 4 12"/></svg>' 128 + '</div>' 129 + '<h3 class="contact-form-success__title">Message sent</h3>' 130 + '<p class="contact-form-success__lede">Thanks - we received your message. ' 131 + 'We reply within 24 hours on workdays, often much sooner.</p>'; 132 form.parentNode.replaceChild(success, form); 133 } else { 134 var msg = (r.data && r.data.error) 135 || 'Could not send right now. Please email us at [email protected] instead.'; 136 setStatus('error', msg); 137 // Turnstile tokens are single-use - reset so a retry gets a fresh one. 138 if (window.turnstile) { try { window.turnstile.reset(); } catch (e) {} } 139 if (submitBtn) { 140 submitBtn.disabled = false;
141 submitBtn.textContent = originalLabel; 142 } 143 } 144 }) 145 .catch(function () { 146 setStatus('error', 'Network error. Please email us at [email protected] instead.'); 147 if (window.turnstile) { try { window.turnstile.reset(); } catch (e) {} } 148 if (submitBtn) { 149 submitBtn.disabled = false; 150 submitBtn.textContent = originalLabel; 151 } 152 }); 153 }); 154 }); 155 156 // ---------- Smooth scroll for in-page anchors ---------- 157 document.querySelectorAll('a[href^="#"]').forEach(function (a) { 158 a.addEventListener('click', function (e) { 159 var target = document.querySelector(a.getAttribute('href')); 160 if (target) { 161 e.preventDefault(); 162 target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 163 } 164 }); 165 }); 166 167})();
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.