1/** 2 * Universal Newsletter Form Handler 3 * 4 * Handles all newsletter form submissions via AJAX. 5 * Expects each form to be inside a .newsletter-form-wrap container 6 * with a data-form-id attribute. 7 * 8 * @package ChildTheme 9 * @since 1.0.0 10 */ 11 12// SVG icons for notification messages (WCAG 2.1 SC 1.4.1 compliance) 13const notificationIcons = { 14 success: '<svg aria-hidden="true" class="icon-success" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm3.7 6.3l-4 4c-.2.2-.4.3-.7.3-.3 0-.5-.1-.7-.3l-2-2c-.4-.4-.4-1 0-1.4.4-.4 1-.4 1.4 0L7 8.2l3.3-3.3c.4-.4 1-.4 1.4 0 .4.4.4 1 0 1.4z" fill="currentColor"/></svg>', 15 error: '<svg aria-hidden="true" class="icon-error" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm3.5 10.1c.4.4.4 1 0 1.4-.2.2-.4.3-.7.3-.3 0-.5-.1-.7-.3L8 9.4l-2.1 2.1c-.2.2-.4.3-.7.3-.3 0-.5-.1-.7-.3-.4-.4-.4-1 0-1.4L6.6 8 4.5 5.9c-.4-.4-.4-1 0-1.4.4-.4 1-.4 1.4 0L8 6.6l2.1-2.1c.4-.4 1-.4 1.4 0 .4.4.4 1 0 1.4L9.4 8l2.1 2.1z" fill="currentColor"/></svg>' 16}; 17 18function setNewsletterMessage(messageDiv, status, text) { 19 if (!messageDiv) return; 20 21 const icon = status === 'success' ? notificationIcons.success : notificationIcons.error; 22 const span = document.createElement('span'); 23 span.textContent = text; 24 25 messageDiv.textContent = ''; 26 messageDiv.insertAdjacentHTML('beforeend', icon); 27 messageDiv.appendChild(span); 28} 29 30document.addEventListener('DOMContentLoaded', function () { 31 const wrappers = document.querySelectorAll('.newsletter-form-wrap'); 32 33 wrappers.forEach(function (wrapper) { 34 const form = wrapper.querySelector('form'); 35 if (!form) return; 36 37 const formId = wrapper.getAttribute('data-form-id'); 38 const messageDiv = wrapper.querySelector('.newsletter-message'); 39 40 // Generate unique ID for message div to support aria-describedby 41 const messageId = 'newsletter-msg-' + (formId || Math.random().toString(36).substr(2, 9)); 42 if (messageDiv) { 43 messageDiv.id = messageId; 44 } 45 46 // Add autocomplete hint for email autofill and accessible label. 47 const emailField = form.querySelector('input[type="email"]'); 48 if (emailField) { 49 if (!emailField.hasAttribute('autocomplete')) { 50 emailField.autocomplete = 'email'; 51 } 52 if (!emailField.hasAttribute('aria-label') && (!emailField.id || !document.querySelector('label[for="' + emailField.id + '"]'))) { 53 emailField.setAttribute('aria-label', 'Email address'); 54 } 55 } 56 57 // Inject honeypot field (hidden from real users, bots will fill it) 58 const honeypot = document.createElement('input'); 59 honeypot.type = 'text'; 60 honeypot.name = 'website_url'; 61 honeypot.value = ''; 62 honeypot.autocomplete = 'off'; 63 honeypot.tabIndex = -1; 64 honeypot.setAttribute('aria-hidden', 'true'); 65 honeypot.setAttribute('aria-label', 'Leave empty'); 66 honeypot.style.cssText = 'position:absolute;left:-9999px;top:-9999px;height:0;width:0;overflow:hidden;'; 67 form.appendChild(honeypot); 68 69 // Cloudflare Turnstile â only when a site key is configured. Rendered here rather 70 // than in the form templates so all six placements get it from one place. The 71 // widget is invisible unless Cloudflare decides a visitor needs an interaction. 72 let turnstileWidget = null; 73 let turnstileResolve = null; 74 const turnstileKey = (typeof childThemeNewsletter !== 'undefined' && childThemeNewsletter.turnstileSiteKey) || ''; 75 if (turnstileKey) { 76 const holder = document.createElement('div'); 77 holder.className = 'newsletter-turnstile'; 78 form.appendChild(holder); 79 const renderTurnstile = function () { 80 if (!window.turnstile || turnstileWidget !== null) return; 81 turnstileWidget = window.turnstile.render(holder, { 82 sitekey: turnstileKey, 83 // Run at submit, not page load, and stay invisible unless Cloudflare 84 // actually needs the visitor to do something. 85 execution: 'execute', 86 appearance: 'interaction-only', 87 action: 'newsletter', 88 callback: function (token) { 89 if (turnstileResolve) { turnstileResolve(token || ''); turnstileResolve = null; } 90 }, 91 'error-callback': function () { 92 // Fail OPEN in the browser too: the server treats an empty token as
93 // "not configured" only when no secret is set, so this simply lets the 94 // request through to the server's own decision. 95 if (turnstileResolve) { turnstileResolve(''); turnstileResolve = null; } 96 } 97 }); 98 }; 99 // api.js may still be loading; poll briefly rather than depending on load order. 100 if (window.turnstile) { 101 renderTurnstile(); 102 } else { 103 let waited = 0; 104 const poll = setInterval(function () { 105 waited += 200; 106 if (window.turnstile || waited > 8000) { 107 clearInterval(poll); 108 renderTurnstile(); 109 } 110 }, 200); 111 } 112 } 113 114 form.addEventListener('submit', function (e) { 115 e.preventDefault(); 116 117 const emailInput = form.querySelector('input[type="email"]'); 118 if (!emailInput) return; 119 120 const email = emailInput.value; 121 const submitBtn = form.querySelector('button[type="submit"], input[type="submit"]'); 122 123 // Resolve a token before submitting. If Cloudflare decides this visitor must 124 // interact, the widget appears HERE and we wait for them to finish rather than 125 // posting an empty token and telling them to reload. Empty string when Turnstile 126 // is not configured â the server treats that as "feature off", not a failure. 127 const getTurnstileToken = function () { 128 return new Promise(function (resolve) { 129 if (turnstileWidget === null || !window.turnstile) { resolve(''); return; } 130 const existing = window.turnstile.getResponse(turnstileWidget) || ''; 131 if (existing) { resolve(existing); return; } 132 turnstileResolve = resolve; 133 try { 134 window.turnstile.execute(turnstileWidget); 135 } catch (err) { 136 turnstileResolve = null; 137 resolve(''); 138 return; 139 } 140 // Never leave the form stuck if the challenge never resolves. 141 setTimeout(function () { 142 if (turnstileResolve) { turnstileResolve(''); turnstileResolve = null; } 143 }, 20000); 144 }); 145 }; 146 147 // Link email input to message div for screen readers 148 if (messageDiv) { 149 emailInput.setAttribute('aria-describedby', messageId); 150 } 151 152 // Clear previous error state 153 emailInput.removeAttribute('aria-invalid'); 154 155 if (messageDiv) { 156 messageDiv.className = 'newsletter-message is-loading'; 157 messageDiv.setAttribute('role', 'status'); 158 messageDiv.textContent = 'Subscribing...'; 159 } 160 161 if (submitBtn) { 162 submitBtn.disabled = true; 163 } 164 165 // Submit the subscription. The endpoint is nonce-free by design 166 // (see includes/ajax.php): a nonce on an anonymous, cached form 167 // only produces stale-cache "Session expired" failures without 168 // adding protection. 169 getTurnstileToken().then(function (turnstileToken) { 170 return fetch(childThemeNewsletter.ajaxUrl, { 171 method: 'POST', 172 headers: { 173 'Content-Type': 'application/x-www-form-urlencoded' 174 }, 175 body: `action=subscribe_newsletter&email=${encodeURIComponent(email)}&form_id=${encodeURIComponent(formId)}&website_url=${encodeURIComponent(honeypot.value)}&cf_turnstile_response=${encodeURIComponent(turnstileToken)}` 176 }) 177 .then(function (response) { return response.json(); }) 178 .then(function (data) { 179 if (messageDiv) { 180 const isSuccess = data && data.success; 181 const errorMsg = data && data.data ? data.data : 'Something went wrong. Please refresh the page and try again.'; 182 const text = isSuccess ? data.data : 'Error: ' + errorMsg; 183 messageDiv.className = 'newsletter-message ' + (isSuccess ? 'is-success' : 'is-error'); 184 messageDiv.setAttribute('role', isSuccess ? 'status' : 'alert'); 185 setNewsletterMessage(messageDiv, isSuccess ? 'success' : 'error', text); 186 } 187 // Turnstile tokens are single-use â reset so a second attempt has a fresh one. 188 if (turnstileWidget !== null && window.turnstile) { 189 window.turnstile.reset(turnstileWidget); 190 } 191 if (data && data.success) { 192 emailInput.removeAttribute('aria-invalid'); 193 emailInput.removeAttribute('aria-describedby'); 194 form.reset(); 195 } else { 196 // Mark input as invalid for screen readers 197 emailInput.setAttribute('aria-invalid', 'true'); 198 if (submitBtn) { 199 submitBtn.disabled = false;
200 } 201 } 202 }) 203 .catch(function () { 204 if (messageDiv) { 205 messageDiv.className = 'newsletter-message is-error'; 206 messageDiv.setAttribute('role', 'alert'); 207 setNewsletterMessage(messageDiv, 'error', 'Error: An error occurred. Please try again.'); 208 } 209 // Mark input as invalid for screen readers 210 emailInput.setAttribute('aria-invalid', 'true'); 211 if (submitBtn) { 212 submitBtn.disabled = false; 213 } 214 }); 215 }); 216 }); 217 }); 218});
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.