1import { Controller } from "@hotwired/stimulus" 2import supportsInteractionObserver from "utils/supports_interaction_observer" 3 4// Connects to data-controller="numiko--newsletter-signup-modal" 5export default class extends Controller { 6 static targets = ["nameFields"] 7 8 connect () { 9 if (!supportsInteractionObserver()) { return; } 10 11 const configDiv = document.getElementById('newsletter-signup-config') 12 if (configDiv && configDiv.dataset.disableModal === 'true') { 13 this.element.remove() 14 return 15 } 16 17 // Mark this modal with its original order before moving to body 18 // so we can keep the first one when removing duplicates 19 if (!this.element.dataset.modalOrder) { 20 const allModals = document.querySelectorAll('#scroll-signup-modal') 21 this.element.dataset.modalOrder = Array.from(allModals).indexOf(this.element) 22 } 23 24 const modal = this.element; 25 if (modal && modal.parentNode !== document.body) document.body.appendChild(modal); 26 this.formSubmitted = false 27 this.#setupObserver() 28 this.#setupModalEvents() 29 } 30 31 disconnect () { 32 if (this.observer) { 33 this.observer.disconnect() 34 } 35 if (this.mutationObserver) { 36 this.mutationObserver.disconnect() 37 } 38 if (this.handleScroll) { 39 window.removeEventListener('scroll', this.handleScroll) 40 } 41 } 42 43 #setupObserver () { 44 this.modalShown = false 45 46 // Check if modal is disabled via config div to allow disabling of individual pages via the CMS 47 // <div id='newsletter-signup-config' data-disable-modal='true'> </div> 48 const configDiv = document.getElementById('newsletter-signup-config') 49 if (configDiv && configDiv.dataset.disableModal === 'true') return 50 51 if (this.getConfigValue('newsletter_modal_completed')) return 52 53 // Don't show the modal if it's already been closed without submission less than 5 minutes ago. 54 const dismissedTime = this.getConfigValue('newsletter_modal_dismissed') 55 if (dismissedTime && Date.now() - dismissedTime < 5 * 60 * 1000) return 56 57 // Ensure only one modal is left on the page if there are multiple 58 // Use a small delay to ensure all controllers have connected and marked their order 59 setTimeout(() => this.removeDuplicateModals(), 100) 60 61 // Scroll-based trigger setup (30% of page height) 62 this.handleScroll = () => { 63 if (this.modalShown) return 64 65 const scrollTop = document.documentElement.scrollTop 66 const documentHeight = document.documentElement.scrollHeight - window.innerHeight 67 const scrollPercentage = (scrollTop / documentHeight) * 100 68 69 if (scrollPercentage >= 30) { 70 const modal = new bootstrap.Modal(this.element) 71 modal.show() 72 this.modalShown = true 73 74 // Remove scroll listener once modal is shown 75 window.removeEventListener('scroll', this.handleScroll) 76 } 77 } 78 79 window.addEventListener('scroll', this.handleScroll) 80 81 // TODO: turbo:frame-load event doesn't seem to be firing so using MutationObserver instead 82 // document.addEventListener('turbo:frame-load', this.closeModal.bind(this)) 83 this.frame = document.getElementById('signup-modal-frame') 84 if (this.frame) { 85 this.mutationObserver = new MutationObserver(() => { 86 // Only close the modal and set the cookie if the frame's been updated with the thanks message 87 if (document.getElementById('signup-completed')) { 88 this.formSubmitted = true 89 this.setNewsletterCookie() 90 this.fireGAEvent() 91 } 92 }) 93 this.mutationObserver.observe(this.frame, { childList: true, subtree: true }) 94 } 95 } 96 97 #setupModalEvents () { 98 // Listen for modal hide events to detect non-submission closure 99 this.element.addEventListener('hidden.bs.modal', () => { 100 if (!this.formSubmitted) { 101 this.setDismissalCookie() 102 } 103 }) 104 } 105 106 closeModal (event) { 107 if (event.target.id === 'newsletter-signup-modal-frame') { 108 const modal = bootstrap.Modal.getInstance(this.element) 109 if (modal) modal.hide() 110 } 111 } 112 113 revealNameFields (event) { 114 if (this.validateEmail(event.target.value)) { 115 this.nameFieldsTarget.classList.add('visible') 116 } else { 117 this.nameFieldsTarget.classList.remove('visible') 118 } 119 } 120 121 fireGAEvent () { 122 window.dataLayer = window.dataLayer || [] 123 window.dataLayer.push ({ 124 event: 'newsletter_modal_signup_completed' 125 }) 126 } 127 128 setNewsletterCookie () { 129 this.setConfigValue('newsletter_modal_completed', true) 130 } 131 132 setDismissalCookie () { 133 this.setConfigValue('newsletter_modal_dismissed', Date.now()) 134 } 135 136 getCookie (name) { 137 return document.cookie.split('; ').find(row => row.startsWith(name + '=')) !== undefined 138 } 139 140 getCookieValue (name) { 141 const cookie = document.cookie.split('; ').find(row => row.startsWith(name + '=')) 142 return cookie ? cookie.split('=')[1] : null 143 } 144 145 // Config-based cookie methods for storing multiple settings in JSON format 146 // TOOD: Extract into a generic utility if needed elsewhere as the config cookie 147 // has already been whitelisted in the cookie-banner so new values can just be added to this
148 getConfigValue (key) { 149 try { 150 const configCookie = this.getCookieValue('config') 151 if (!configCookie) return false 152 const config = JSON.parse(decodeURIComponent(configCookie)) 153 return config[key] || false 154 } catch (e) { 155 return false 156 } 157 } 158 159 setConfigValue (key, value) { 160 try { 161 let config = {} 162 const existingConfig = this.getCookieValue('config') 163 if (existingConfig) { 164 config = JSON.parse(decodeURIComponent(existingConfig)) 165 } 166 167 config[key] = value 168 169 const three_months = 60 * 60 * 24 * 90 170 const encodedConfig = encodeURIComponent(JSON.stringify(config)) 171 document.cookie = `config=${encodedConfig}; path=/; max-age=${three_months}` 172 } catch (e) { 173 console.warn('Failed to set config value:', e) 174 } 175 } 176 177 removeDuplicateModals () { 178 const allModals = document.querySelectorAll('#scroll-signup-modal') 179 const allTriggers = document.querySelectorAll('#signup-modal') 180 181 // Keep the modal with the lowest order (the original first one) 182 if (allModals.length > 1) { 183 const modalArray = Array.from(allModals) 184 const sortedModals = modalArray.sort((a, b) => { 185 const orderA = parseInt(a.dataset.modalOrder || '0') 186 const orderB = parseInt(b.dataset.modalOrder || '0') 187 return orderA - orderB 188 }) 189 190 // Remove all except the first (lowest order) 191 for (let i = 1; i < sortedModals.length; i++) { 192 sortedModals[i].remove() 193 } 194 } 195 196 if (allTriggers.length > 1) { 197 for (let i = 1; i < allTriggers.length; i++) { 198 allTriggers[i].remove() 199 } 200 } 201 } 202 203 validateEmail (email) { 204 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) 205 } 206}
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.