1import { Controller } from "@hotwired/stimulus"; 2 3// Connects to data-controller="banner" 4export default class extends Controller { 5 static targets = ["days", "hours", "minutes", "seconds"]; 6 static values = { 7 cookieName: { type: String, default: "banner_dismissed" }, // The name of the cookie to store the banner dismissal 8 cookieDays: { type: Number, default: 0 }, // How long the cookie should persist (-1 = no cookie, 0 = session only, >0 = days) 9 countdownEndTime: { type: String, default: "" }, // ISO 8601 date string (e.g., "2024-12-31T23:59:59") 10 autoHide: { type: Boolean, default: false }, // Auto hide after countdown expires (I recommend setting this to true by default) 11 }; 12 13 connect() { 14 // Check if banner was previously dismissed - do this BEFORE showing anything 15 if (this.isBannerDismissed()) { 16 // Don't show the banner at all, just remove from DOM 17 return; 18 } 19 20 // Check if countdown has already expired (only if countdown is configured) 21 if (this.countdownEndTimeValue) { 22 const endTime = new Date(this.countdownEndTimeValue); 23 if (!isNaN(endTime.getTime()) && endTime <= new Date()) { 24 // Countdown already expired, don't show banner 25 return; 26 } 27 } 28 29 // Check if banner has been shown before (to skip animation on subsequent page loads) 30 const hasBeenShown = this.hasBannerBeenShown(); 31 32 // Banner should be shown - remove hidden class 33 this.element.classList.remove("hidden"); 34 35 if (hasBeenShown) { 36 // Already shown before - skip animation and show immediately 37 this.element.classList.remove("opacity-0", "-translate-y-full", "translate-y-full"); 38 this.element.classList.add("opacity-100", "translate-y-0"); 39 } else { 40 // First time showing - animate in 41 requestAnimationFrame(() => { 42 this.element.classList.remove("opacity-0"); 43 // Remove only the y-axis translations, preserve x-axis (for centered banners) 44 this.element.classList.remove("-translate-y-full", "translate-y-full"); 45 this.element.classList.add("opacity-100", "translate-y-0"); 46 }); 47 // Mark banner as shown (session cookie so it persists across page navigations) 48 this.markBannerAsShown(); 49 } 50 51 // Initialize countdown if we have a target end time 52 if (this.countdownEndTimeValue) { 53 this.initializeCountdown(); 54 } 55 } 56 57 disconnect() { 58 if (this.countdownTimer) { 59 clearInterval(this.countdownTimer); 60 } 61 } 62 63 initializeCountdown() { 64 // Parse the end time as ISO 8601 date string 65 const endTime = new Date(this.countdownEndTimeValue); 66 67 // Validate the date 68 if (isNaN(endTime.getTime())) { 69 console.error( 70 "Invalid countdown end time. Please provide a valid ISO 8601 date string (e.g., '2024-12-31T23:59:59'):", 71 this.countdownEndTimeValue, 72 ); 73 return; 74 } 75 76 this.endTime = endTime; 77 78 // Update immediately 79 this.updateCountdown(); 80 81 // Update every second 82 this.countdownTimer = setInterval(() => { 83 this.updateCountdown(); 84 }, 1000); 85 } 86 87 updateCountdown() { 88 const now = new Date(); 89 const timeRemaining = this.endTime - now; 90 91 if (timeRemaining <= 0) { 92 // Countdown expired 93 this.handleCountdownExpired(); 94 return; 95 } 96 97 // Calculate days, hours, minutes, seconds 98 const days = Math.floor(timeRemaining / (1000 * 60 * 60 * 24)); 99 const hours = Math.floor((timeRemaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); 100 const minutes = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60)); 101 const seconds = Math.floor((timeRemaining % (1000 * 60)) / 1000); 102 103 // Update targets if they exist 104 if (this.hasDaysTarget) { 105 this.daysTarget.textContent = String(days).padStart(2, "0"); 106 } 107 if (this.hasHoursTarget) { 108 this.hoursTarget.textContent = String(hours).padStart(2, "0"); 109 } 110 if (this.hasMinutesTarget) { 111 this.minutesTarget.textContent = String(minutes).padStart(2, "0"); 112 } 113 if (this.hasSecondsTarget) { 114 this.secondsTarget.textContent = String(seconds).padStart(2, "0"); 115 } 116 } 117 118 handleCountdownExpired() { 119 // Clear the interval 120 if (this.countdownTimer) { 121 clearInterval(this.countdownTimer); 122 this.countdownTimer = null; 123 } 124 125 // Display zeros 126 if (this.hasDaysTarget) this.daysTarget.textContent = "00"; 127 if (this.hasHoursTarget) this.hoursTarget.textContent = "00"; 128 if (this.hasMinutesTarget) this.minutesTarget.textContent = "00"; 129 if (this.hasSecondsTarget) this.secondsTarget.textContent = "00"; 130 131 // Auto hide if configured 132 if (this.autoHideValue) { 133 setTimeout(() => { 134 this.hide(); 135 }, 2000); // Wait 2 seconds before hiding 136 } 137 } 138 139 hide(event) { 140 if (event) event.preventDefault(); 141 142 this.#performHide(); 143 } 144 145 // Hide without preventing default - use this for links that should navigate 146 hideAndNavigate(event) { 147 // Set cookie immediately (before navigation) so it persists 148 if (this.cookieDaysValue !== -1) { 149 this.setCookie(this.cookieNameValue, "true", this.cookieDaysValue); 150 } 151 152 // Don't prevent default - allow links to navigate 153 this.#performHide(true); // Pass true to skip cookie setting in setTimeout 154 } 155 156 #performHide(skipCookie = false) { 157 // Add exit animation using Tailwind classes 158 this.element.classList.remove("opacity-100", "translate-y-0"); 159 this.element.classList.add("opacity-0"); 160 161 // Add the appropriate slide out based on position 162 if (this.element.classList.contains("top-0")) { 163 this.element.classList.add("-translate-y-full"); 164 } else if (this.element.classList.contains("bottom-0")) { 165 this.element.classList.add("translate-y-full"); 166 } 167 168 // Wait for animation to complete before removing 169 setTimeout(() => { 170 // Set cookie to remember dismissal (only if not already set) 171 if (!skipCookie && this.cookieDaysValue !== -1) { 172 this.setCookie(this.cookieNameValue, "true", this.cookieDaysValue); 173 } 174 175 // Remove element from DOM when using cookies, otherwise just hide it 176 if (this.cookieDaysValue !== -1) { 177 this.element.remove(); 178 } else { 179 // When cookieDays is -1, keep element in DOM but hide it for refresh functionality 180 this.element.classList.add("hidden"); 181 } 182 }, 300); // Match the transition duration 183 } 184 185 // Cookie management methods 186 setCookie(name, value, days) { 187 if (days === 0) { 188 // Session cookie (no expiration - lasts until browser closes) 189 document.cookie = name + "=" + value + ";path=/;SameSite=Lax"; 190 } else { 191 // Persistent cookie with expiration 192 const date = new Date(); 193 date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); 194 const expires = "expires=" + date.toUTCString(); 195 document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Lax"; 196 } 197 } 198 199 getCookie(name) { 200 const nameEQ = name + "="; 201 const cookies = document.cookie.split(";"); 202 for (let i = 0; i < cookies.length; i++) { 203 let cookie = cookies[i]; 204 while (cookie.charAt(0) === " ") { 205 cookie = cookie.substring(1, cookie.length); 206 } 207 if (cookie.indexOf(nameEQ) === 0) { 208 return cookie.substring(nameEQ.length, cookie.length); 209 } 210 } 211 return null; 212 } 213 214 isBannerDismissed() { 215 return this.getCookie(this.cookieNameValue) === "true"; 216 } 217 218 hasBannerBeenShown() { 219 const shownCookieName = this.cookieNameValue + "_shown"; 220 return this.getCookie(shownCookieName) === "true"; 221 } 222 223 markBannerAsShown() { 224 const shownCookieName = this.cookieNameValue + "_shown"; 225 // Use session cookie (0 days) so it persists across page navigations but clears when browser closes 226 this.setCookie(shownCookieName, "true", 0); 227 } 228};
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.