1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static targets = ["email", "form", "successMessage", "submitButton"]; 5 6 async submit(event) { 7 event.preventDefault(); 8 9 const email = this.emailTarget.value.trim(); 10 11 if (!email) { 12 this.showError("Please enter your email address"); 13 return; 14 } 15 16 if (!this.isValidEmail(email)) { 17 this.showError("Please enter a valid email address"); 18 return; 19 } 20 21 this.setLoading(true); 22 23 // Get Turnstile controller 24 const turnstileElement = this.element.querySelector('[data-controller*="turnstile"]'); 25 const turnstileController = turnstileElement 26 ? this.application.getControllerForElementAndIdentifier(turnstileElement, "turnstile") 27 : null; 28 29 // Check if we already have a token 30 let turnstileToken = turnstileController?.getToken(); 31 32 // If no token, execute the challenge and wait for it 33 if (!turnstileToken && turnstileController) { 34 // Execute the challenge 35 turnstileController.execute(); 36 37 // Wait for the turnstile:success event 38 try { 39 await this.waitForTurnstile(turnstileElement); 40 turnstileToken = turnstileController.getToken(); 41 } catch (error) { 42 console.error("Turnstile verification failed:", error); 43 this.setLoading(false); 44 this.showError("Verification failed. Please try again."); 45 return; 46 } 47 } 48 49 // Fallback: check input field 50 if (!turnstileToken) { 51 const tokenInput = this.element.querySelector('input[name="cf-turnstile-response"]'); 52 turnstileToken = tokenInput?.value; 53 } 54 55 if (!turnstileToken) { 56 console.error("No Turnstile token available"); 57 this.setLoading(false); 58 this.showError("Verification failed. Please try again."); 59 return; 60 } 61 62 try { 63 const response = await fetch("/newsletter", { 64 method: "POST", 65 headers: { 66 "Content-Type": "application/json", 67 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, 68 }, 69 body: JSON.stringify({ 70 email: email, 71 "cf-turnstile-response": turnstileToken, 72 }), 73 }); 74 75 const data = await response.json(); 76 77 if (response.ok) { 78 this.showSuccess(); 79 80 // Track successful newsletter signup 81 if (typeof fathom !== "undefined") { 82 fathom.trackEvent("newsletter signup"); 83 } 84 85 // Track with PostHog 86 if (typeof posthog !== "undefined") { 87 posthog.capture("newsletter signup", { 88 email: email, 89 }); 90 } 91 } else { 92 this.showError(data.error || "Something went wrong. Please try again."); 93 } 94 } catch (error) { 95 console.error("Newsletter subscription error:", error); 96 this.showError("Something went wrong. Please try again."); 97 } finally { 98 this.setLoading(false); 99 } 100 } 101 102 showSuccess() { 103 this.formTarget.style.display = "none"; 104 this.successMessageTarget.style.display = "block"; 105 } 106 107 showError(message) { 108 // Remove any existing error messages 109 const existingError = this.element.querySelector(".error-message"); 110 if (existingError) { 111 existingError.remove(); 112 } 113 114 // Create and show error message 115 const errorDiv = document.createElement("div"); 116 errorDiv.className = "error-message mt-2 text-sm text-red-600 dark:text-red-400"; 117 errorDiv.textContent = message; 118 this.formTarget.appendChild(errorDiv); 119 120 // Remove error message after 5 seconds 121 setTimeout(() => { 122 if (errorDiv.parentNode) { 123 errorDiv.remove(); 124 } 125 }, 5000); 126 } 127 128 setLoading(loading) { 129 if (loading) { 130 this.submitButtonTarget.disabled = true; 131 this.submitButtonTarget.textContent = "Subscribing..."; 132 this.submitButtonTarget.classList.add("opacity-50", "cursor-not-allowed"); 133 } else { 134 this.submitButtonTarget.disabled = false; 135 this.submitButtonTarget.textContent = "Subscribe"; 136 this.submitButtonTarget.classList.remove("opacity-50", "cursor-not-allowed"); 137 } 138 } 139 140 isValidEmail(email) { 141 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 142 return emailRegex.test(email); 143 } 144 145 waitForTurnstile(element, timeout = 10000) { 146 return new Promise((resolve, reject) => { 147 const timer = setTimeout(() => { 148 reject(new Error("Turnstile verification timeout")); 149 }, timeout); 150 151 const successHandler = () => { 152 clearTimeout(timer); 153 element.removeEventListener("turnstile:success", successHandler); 154 element.removeEventListener("turnstile:error", errorHandler); 155 resolve(); 156 }; 157 158 const errorHandler = () => { 159 clearTimeout(timer); 160 element.removeEventListener("turnstile:success", successHandler); 161 element.removeEventListener("turnstile:error", errorHandler); 162 reject(new Error("Turnstile verification error")); 163 }; 164 165 element.addEventListener("turnstile:success", successHandler); 166 element.addEventListener("turnstile:error", errorHandler); 167 }); 168 } 169};
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.