1/** 2 * Announcement Bar Frontend JavaScript 3 * 4 * Handles close buttons, reopening, countdown timers, coupon copying, 5 * and other interactive features for announcement bars. 6 * 7 * DEBUG: Set window.HASHBAR_DEBUG = true in browser console to enable debug logging 8 */ 9 10(function() { 11 'use strict'; 12 13 // Prevent double initialization if multiple scripts are loaded 14 if (window.HASHBAR_ANNOUNCEMENT_INITIALIZED) { 15 return; 16 } 17 window.HASHBAR_ANNOUNCEMENT_INITIALIZED = true; 18 19 // Enable debug mode if URL has debug parameter or localStorage flag 20 if (typeof window.HASHBAR_DEBUG === 'undefined') { 21 window.HASHBAR_DEBUG = 22 new URLSearchParams(window.location.search).has('hashbar_debug') || 23 localStorage.getItem('hashbar_debug') === 'true'; 24 } 25 26 /** 27 * Initialize announcement bars 28 */ 29 document.addEventListener('DOMContentLoaded', function() { 30 initializeAnnouncementBars(); 31 }); 32 33 /** 34 * Check if bar should be displayed based on visitor timezone schedule 35 * This handles start/end dates, time targeting and recurring days when timezone is set to 'visitor' 36 * 37 * @param {HTMLElement} bar The announcement bar element 38 * @returns {boolean} True if bar should be displayed, false otherwise 39 */ 40 function checkVisitorTimezoneSchedule(bar) { 41 const scheduleEnabled = bar.getAttribute('data-schedule-enabled') === 'true'; 42 const scheduleTimezone = bar.getAttribute('data-schedule-timezone'); 43 44 // If schedule is not enabled, always display 45 if (!scheduleEnabled) { 46 return true; 47 } 48 49 // Only apply visitor timezone check when timezone is 'visitor' 50 // 'site' timezone checks are handled server-side 51 if (scheduleTimezone !== 'visitor') { 52 return true; 53 } 54 55 const now = new Date(); 56 const currentDay = now.getDay(); // 0 = Sunday, 1 = Monday, etc. 57 const currentHours = now.getHours(); 58 const currentMinutes = now.getMinutes(); 59 const currentTimeMinutes = currentHours * 60 + currentMinutes; 60 61 // Check start/end date range (for visitor timezone) 62 const scheduleStart = bar.getAttribute('data-schedule-start'); 63 const scheduleEnd = bar.getAttribute('data-schedule-end'); 64 65 // If start date is set, check if we've reached it 66 if (scheduleStart) { 67 // Parse the datetime-local format (YYYY-MM-DDTHH:MM) 68 const startParts = scheduleStart.split('T'); 69 const startDateParts = startParts[0].split('-'); 70 const startTimeParts = (startParts[1] || '00:00').split(':'); 71 72 const startDate = new Date( 73 parseInt(startDateParts[0], 10), 74 parseInt(startDateParts[1], 10) - 1, 75 parseInt(startDateParts[2], 10), 76 parseInt(startTimeParts[0], 10), 77 parseInt(startTimeParts[1] || 0, 10), 78 0, 0 79 ); 80 81 if (now < startDate) { 82 return false; // Start date not reached yet 83 } 84 } 85 86 // If end date is set, check if it has passed 87 if (scheduleEnd) { 88 // Parse the datetime-local format (YYYY-MM-DDTHH:MM) 89 const endParts = scheduleEnd.split('T'); 90 const endDateParts = endParts[0].split('-'); 91 const endTimeParts = (endParts[1] || '23:59').split(':'); 92 93 const endDate = new Date( 94 parseInt(endDateParts[0], 10), 95 parseInt(endDateParts[1], 10) - 1, 96 parseInt(endDateParts[2], 10), 97 parseInt(endTimeParts[0], 10), 98 parseInt(endTimeParts[1] || 59, 10), 99 0, 0 100 ); 101 102 if (now > endDate) { 103 return false; // End date has passed 104 } 105 } 106 107 // Check recurring days (if enabled) 108 const scheduleRecurring = bar.getAttribute('data-schedule-recurring') === 'true'; 109 if (scheduleRecurring) { 110 let recurringDays = [];
111 try { 112 recurringDays = JSON.parse(bar.getAttribute('data-schedule-recurring-days') || '[]'); 113 } catch (e) { 114 recurringDays = []; 115 } 116 117 // If recurring days are set, check if today is an active day 118 if (recurringDays.length > 0) { 119 // Convert day names to day numbers for comparison 120 const dayNameToNumber = { 121 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3, 122 'thursday': 4, 'friday': 5, 'saturday': 6 123 }; 124 125 const activeDayNumbers = recurringDays.map(function(day) { 126 return typeof day === 'string' ? dayNameToNumber[day.toLowerCase()] : day; 127 }).filter(function(num) { 128 return num !== undefined; 129 }); 130 131 if (activeDayNumbers.indexOf(currentDay) === -1) { 132 return false; // Today is not an active day 133 } 134 } 135 } 136 137 // Check time targeting (if enabled) 138 const timeTargeting = bar.getAttribute('data-schedule-time-targeting') === 'true'; 139 if (timeTargeting) { 140 const timeStart = bar.getAttribute('data-schedule-time-start') || '00:00'; 141 const timeEnd = bar.getAttribute('data-schedule-time-end') || '23:59'; 142 143 // Parse start and end times 144 const startParts = timeStart.split(':'); 145 const endParts = timeEnd.split(':'); 146 const startMinutes = parseInt(startParts[0], 10) * 60 + parseInt(startParts[1] || 0, 10); 147 const endMinutes = parseInt(endParts[0], 10) * 60 + parseInt(endParts[1] || 0, 10); 148 149 // Check if current time is within the allowed range 150 if (endMinutes >= startMinutes) { 151 // Normal range (e.g., 09:00 to 17:00) 152 if (currentTimeMinutes < startMinutes || currentTimeMinutes > endMinutes) { 153 return false; 154 } 155 } else { 156 // Overnight range (e.g., 22:00 to 06:00) 157 if (currentTimeMinutes < startMinutes && currentTimeMinutes > endMinutes) { 158 return false; 159 } 160 } 161 } 162 163 return true; 164 } 165 166 /** 167 * Main initialization function 168 */ 169 function initializeAnnouncementBars() { 170 const bars = document.querySelectorAll('.hashbar-announcement-bar'); 171 172 if (bars.length === 0) { 173 return; 174 } 175 176 bars.forEach(function(bar) { 177 const barId = bar.getAttribute('data-bar-id'); 178 179 // Check visitor timezone schedule before showing bar 180 if (!checkVisitorTimezoneSchedule(bar)) { 181 bar.style.display = 'none'; 182 return; // Don't initialize this bar - it shouldn't show based on visitor timezone schedule 183 } 184 185 // Initialize CTA button with first message content 186 initializeCTAButton(bar, barId); 187 188 // Initialize time-on-site behavioral targeting 189 if (bar.getAttribute('data-time-on-site-enabled') === 'true') { 190 initializeTimeOnSiteTargeting(bar, barId); 191 } else { 192 // If time-on-site targeting is not enabled, show bar immediately 193 showBar(bar); 194 } 195 196 // Initialize close button 197 initializeCloseButton(bar, barId); 198 199 // Initialize reopen button 200 initializeReopenButton(bar, barId); 201 202 // Initialize countdown timer 203 if (bar.getAttribute('data-countdown-enabled') === 'true') { 204 initializeCountdown(bar, barId); 205 } 206 207 // Initialize coupon copy 208 initializeCouponCopy(bar, barId); 209 210 // Initialize message rotation 211 if (bar.getAttribute('data-message-rotation-enabled') === 'true') { 212 initializeMessageRotation(bar, barId); 213 } 214 215 // Initialize exit animation 216 setupExitAnimation(bar); 217 218 // Track A/B test variant (impression tracking happens when bar is displayed) 219 if (bar.getAttribute('data-ab-test-enabled') === 'true') { 220 // Delay tracking impression until bar is actually visible 221 setTimeout(function() { 222 trackABTestVariant(bar, barId); 223 }, 100); 224 } 225 }); 226 } 227 228 /** 229 * Initialize time-on-site behavioral targeting 230 * 231 * This function tracks the elapsed time a visitor has spent on the page 232 * and shows the announcement bar only after the configured minimum time threshold is reached. 233 */ 234 function initializeTimeOnSiteTargeting(bar, barId) { 235 const minimumTimeSeconds = parseInt(bar.getAttribute('data-minimum-time-on-site'), 10) || 0; 236 237 // If minimum time is 0 or not set, show immediately 238 if (minimumTimeSeconds <= 0) { 239 showBar(bar); 240 return; 241 } 242 243 // Get or create session start time 244 const sessionStartKey = 'hashbar_session_start_' + barId; 245 let sessionStart = sessionStorage.getItem(sessionStartKey); 246 247 if (!sessionStart) { 248 // Store current time as session start 249 sessionStart = Date.now(); 250 sessionStorage.setItem(sessionStartKey, sessionStart); 251 } 252 253 // Check if we've already shown this bar 254 const barShownKey = 'hashbar_shown_' + barId; 255 const barAlreadyShown = sessionStorage.getItem(barShownKey); 256 257 if (barAlreadyShown === 'true') { 258 // Bar was already shown in this session, show it immediately 259 showBar(bar); 260 return; 261 } 262 263 // Calculate elapsed time 264 const sessionStart_ms = parseInt(sessionStart, 10); 265 const elapsedSeconds = Math.floor((Date.now() - sessionStart_ms) / 1000); 266 267 268 if (elapsedSeconds >= minimumTimeSeconds) { 269 // Minimum time has been reached, show the bar 270 showBar(bar); 271 sessionStorage.setItem(barShownKey, 'true'); 272 } else { 273 // Hide the bar initially, will show when time threshold is reached 274 bar.style.display = 'none'; 275 276 // Calculate time remaining until bar should be shown 277 const timeRemaining = minimumTimeSeconds - elapsedSeconds; 278 279 // Set timeout to show bar after remaining time elapses 280 setTimeout(function() { 281 showBar(bar); 282 sessionStorage.setItem(barShownKey, 'true'); 283 }, timeRemaining * 1000); 284 } 285 } 286 287 /** 288 * Initialize close button functionality 289 */ 290 function initializeCloseButton(bar, barId) { 291 const closeButtons = bar.querySelectorAll('.hashbar-announcement-close'); 292 293 closeButtons.forEach(function(button) { 294 button.addEventListener('click', function(e) { 295 e.preventDefault(); 296 closeAnnouncement(bar, barId); 297 }); 298 }); 299 } 300 301 /** 302 * Close announcement bar with animation and smooth height collapse 303 */ 304 function closeAnnouncement(bar, barId) { 305 var exitAnimation = bar.getAttribute('data-animation-exit'); 306 var animationDuration = parseInt(bar.getAttribute('data-animation-duration'), 10) || 500; 307 var wrapper = bar.closest('.hashbar-announcement-bar-wrapper') || bar; 308 309 var sticky = isBarSticky(bar); 310 var durSec = animationDuration / 1000; 311 312 if (sticky) { 313 // For top-sticky (position:sticky in PHP) the bar is in document flow, so shrinking its 314 // own height reflows the page and the header rises in perfect lockstep â a single 315 // browser-driven motion. For bottom-sticky (position:fixed) we additionally shrink the 316 // body padding-bottom spacer with matching duration + easing. 317 // PHP renders inline min-height + padding; both must be overridden so height:0 actually 318 // takes effect, otherwise the bar stays visible while everything around it animates. 319 beginStickySpacerCollapse(bar, animationDuration, 'ease-in'); 320 var currentHeight = bar.offsetHeight; 321 bar.style.overflow = 'hidden'; 322 bar.style.minHeight = '0'; 323 bar.style.height = currentHeight + 'px'; 324 bar.offsetHeight; // force reflow 325 bar.style.transition = 'height ' + durSec + 's ease-in, opacity ' + durSec + 's ease-in, padding ' + durSec + 's ease-in'; 326 bar.style.height = '0px'; 327 bar.style.paddingTop = '0'; 328 bar.style.paddingBottom = '0'; 329 bar.style.opacity = '0'; 330 if (exitAnimation && exitAnimation !== 'none') { 331 // Layered visual effect (e.g. slideUp) on top of the height collapse. 332 bar.style.animation = exitAnimation + ' ' + durSec + 's ease-in forwards'; 333 } 334 } else { 335 // Non-sticky: bar is in flow inside the wrapper, so collapse wrapper height as before. 336 wrapper.style.overflow = 'hidden'; 337 wrapper.style.height = wrapper.offsetHeight + 'px'; 338 wrapper.offsetHeight; // force reflow 339 wrapper.style.transition = 'height ' + durSec + 's ease'; 340 wrapper.style.height = '0px'; 341 342 if (exitAnimation && exitAnimation !== 'none') { 343 bar.style.animation = exitAnimation + ' ' + durSec + 's ease-in forwards'; 344 } else { 345 bar.style.transition = 'opacity ' + durSec + 's ease'; 346 bar.style.opacity = '0'; 347 } 348 } 349 350 // After animation completes, hide bar and set cookie 351 setTimeout(function() { 352 bar.style.display = 'none'; 353 removeStickySpacer(bar); 354 355 var cookieDays = parseFloat(bar.getAttribute('data-cookie-duration')); 356 357 // -1 means "show on reload" - don't set any cookie, just hide the bar 358 if (cookieDays === -1) { 359 var reopenBtnReload = document.querySelector('.hashbar-reopen-button[data-bar-id="' + barId + '"]'); 360 if (reopenBtnReload) { 361 reopenBtnReload.style.display = 'block'; 362 } 363 return; 364 } 365 366 // Store close timestamp so PHP can compare against current setting 367 var closeTimestamp = Date.now().toString(); 368 369 // 0 means "session only" - set session cookie (no expiration) 370 if (cookieDays === 0) {
371 setCookie('hashbar_announcement_closed_' + barId, closeTimestamp, null); 372 var reopenBtn0 = document.querySelector('.hashbar-reopen-button[data-bar-id="' + barId + '"]'); 373 if (reopenBtn0) { 374 reopenBtn0.style.display = 'block'; 375 } 376 return; 377 } 378 379 // Set cookie with 1-year expiry - PHP handles the real duration check 380 setCookie('hashbar_announcement_closed_' + barId, closeTimestamp, 365); 381 382 // Show reopen button if enabled 383 var reopenButton = document.querySelector('.hashbar-reopen-button[data-bar-id="' + barId + '"]'); 384 if (reopenButton) { 385 reopenButton.style.display = 'block'; 386 } 387 }, animationDuration); 388 } 389 390 /** 391 * Show bar - entry animation is handled by PHP inline styles 392 */ 393 function showBar(bar) { 394 bar.style.display = 'flex'; 395 applyStickySpacer(bar); 396 } 397 398 // Registry of sticky bar heights by position, keyed by bar id. 399 // Lets us push body content down by the combined sticky-bar height, 400 // so a position:fixed bar never overlaps the site header on first paint. 401 var hashbarStickyRegistry = { top: {}, bottom: {} }; 402 var hashbarStickyObservers = {}; 403 404 function isBarSticky(bar) { 405 var v = bar.getAttribute('data-sticky'); 406 return v === '1' || v === 'true'; 407 } 408 409 function getBarPosition(bar) { 410 return bar.getAttribute('data-position') === 'bottom' ? 'bottom' : 'top'; 411 } 412 413 // Body-padding spacer is only needed for bottom-sticky bars (position:fixed, out of flow). 414 // Top-sticky bars use position:sticky in PHP, which keeps them in flow â the document layout 415 // already reserves their space, so no manual spacer is required. 416 function needsBodySpacer(bar) { 417 return isBarSticky(bar) && getBarPosition(bar) === 'bottom'; 418 } 419 420 function applyStickySpacer(bar) { 421 if (!bar || !needsBodySpacer(bar)) return; 422 var barId = bar.getAttribute('data-bar-id'); 423 if (!barId) return; 424 var position = getBarPosition(bar); 425 var height = bar.offsetHeight; 426 if (!height) return; 427 hashbarStickyRegistry[position][barId] = height; 428 updateBodySpacer(position); 429 430 if (!hashbarStickyObservers[barId] && typeof ResizeObserver !== 'undefined') { 431 var ro = new ResizeObserver(function() { 432 if (bar.style.display === 'none') return; 433 hashbarStickyRegistry[position][barId] = bar.offsetHeight; 434 updateBodySpacer(position); 435 }); 436 ro.observe(bar); 437 hashbarStickyObservers[barId] = ro; 438 } 439 } 440 441 // Called the moment the close button is clicked. Mirrors the non-sticky wrapper-collapse 442 // pattern: pin the current computed padding inline, force a reflow, then transition to the 443 // new (post-removal) total. The PHP-emitted pre-paint <style> is dropped first so it can't 444 // override the inline transition target back up to its baked-in value. Easing must match the 445 // bar's own collapse easing or the two motions visually finish at different times. 446 function beginStickySpacerCollapse(bar, durationMs, easing) { 447 if (!bar || !needsBodySpacer(bar)) return; 448 var barId = bar.getAttribute('data-bar-id'); 449 if (!barId) return; 450 var position = getBarPosition(bar); 451 if (!(barId in hashbarStickyRegistry[position])) return; 452 delete hashbarStickyRegistry[position][barId]; 453 if (hashbarStickyObservers[barId]) { 454 hashbarStickyObservers[barId].disconnect(); 455 delete hashbarStickyObservers[barId]; 456 } 457 var preStyle = document.getElementById('hashbar-pre-spacer-' + barId); 458 if (preStyle && preStyle.parentNode) preStyle.parentNode.removeChild(preStyle); 459 var prop = position === 'bottom' ? 'paddingBottom' : 'paddingTop'; 460 var transProp = position === 'bottom' ? 'padding-bottom' : 'padding-top'; 461 var total = 0, reg = hashbarStickyRegistry[position]; 462 for (var k in reg) total += reg[k]; 463 var current = parseFloat(window.getComputedStyle(document.body)[prop]) || 0; 464 document.body.style.transition = ''; 465 document.body.style[prop] = current + 'px'; 466 document.body.offsetHeight; // force reflow before applying transition 467 document.body.style.transition = transProp + ' ' + (durationMs / 1000) + 's ' + (easing || 'ease'); 468 document.body.style[prop] = total + 'px'; 469 } 470 471 function removeStickySpacer(bar) { 472 if (!bar) return; 473 var barId = bar.getAttribute('data-bar-id'); 474 if (!barId) return; 475 var position = getBarPosition(bar);
476 delete hashbarStickyRegistry[position][barId]; 477 if (hashbarStickyObservers[barId]) { 478 hashbarStickyObservers[barId].disconnect(); 479 delete hashbarStickyObservers[barId]; 480 } 481 var preStyle = document.getElementById('hashbar-pre-spacer-' + barId); 482 if (preStyle && preStyle.parentNode) preStyle.parentNode.removeChild(preStyle); 483 document.body.style.transition = ''; 484 updateBodySpacer(position); 485 } 486 487 function updateBodySpacer(position) { 488 var prop = position === 'bottom' ? 'paddingBottom' : 'paddingTop'; 489 var total = 0; 490 var heights = hashbarStickyRegistry[position]; 491 for (var k in heights) { 492 if (Object.prototype.hasOwnProperty.call(heights, k)) total += heights[k]; 493 } 494 if (total > 0) { 495 document.body.style[prop] = total + 'px'; 496 } else { 497 document.body.style[prop] = ''; 498 } 499 } 500 501 window.addEventListener('resize', function() { 502 document.querySelectorAll('.hashbar-announcement-bar').forEach(function(bar) { 503 if (bar.style.display === 'none') return; 504 applyStickySpacer(bar); 505 }); 506 }); 507 508 /** 509 * Initialize reopen button functionality 510 */ 511 function initializeReopenButton(bar, barId) { 512 const reopenButton = document.querySelector('.hashbar-reopen-button[data-bar-id="' + barId + '"]'); 513 514 if (!reopenButton) { 515 return; 516 } 517 518 const reopenBtn = reopenButton.querySelector('.hashbar-reopen-btn'); 519 520 if (reopenBtn) { 521 reopenBtn.addEventListener('click', function(e) { 522 e.preventDefault(); 523 reopenAnnouncement(bar, barId, reopenButton); 524 }); 525 } 526 } 527 528 /** 529 * Reopen a closed announcement bar 530 */ 531 function reopenAnnouncement(bar, barId, reopenButton) { 532 // Clear the closed cookie 533 setCookie('hashbar_announcement_closed_' + barId, '', -1); 534 535 // Reset bar and wrapper for re-display 536 var wrapper = bar.closest('.hashbar-announcement-bar-wrapper') || bar; 537 wrapper.style.overflow = ''; 538 wrapper.style.height = ''; 539 wrapper.style.transition = ''; 540 bar.style.display = 'flex'; 541 bar.style.opacity = '1';
542 bar.style.animation = 'none'; 543 544 // Show bar with entry animation 545 showBar(bar); 546 547 // Hide reopen button 548 reopenButton.style.display = 'none'; 549 } 550 551 /** 552 * Initialize countdown timer - handles fixed, recurring, and evergreen types 553 */ 554 function initializeCountdown(bar, barId) { 555 // Prevent multiple initializations on the same bar 556 if (bar.hasAttribute('data-countdown-initialized')) { 557 return; 558 } 559 bar.setAttribute('data-countdown-initialized', 'true'); 560 561 const countdownType = bar.getAttribute('data-countdown-type'); 562 const countdownDate = bar.getAttribute('data-countdown-date'); 563 const timezoneStr = bar.getAttribute('data-countdown-timezone'); 564 565 // Track countdown view for A/B testing (before any early returns) 566 if (bar.getAttribute('data-ab-test-enabled') === 'true') { 567 const abTestVariantId = bar.getAttribute('data-ab-test-variant-id'); 568 trackABTestEvent(barId, abTestVariantId, 'countdown_view'); 569 } 570 571 // Find the bar wrapper 572 const barWrapper = bar.parentElement && bar.parentElement.classList.contains('hashbar-announcement-bar-wrapper') 573 ? bar.parentElement 574 : bar; 575 576 // Query all countdown timers 577 const timerElements = barWrapper.querySelectorAll('.hashbar-countdown-timer-wrapper, .hashbar-countdown-timer-text'); 578 579 if (timerElements.length === 0) { 580 return; 581 } 582 583 // Copy color/label settings from bar to each timer element for use during rendering 584 var cdBgColor = bar.getAttribute('data-countdown-bg-color') || ''; 585 var cdTextColor = bar.getAttribute('data-countdown-text-color') || ''; 586 var cdLabelColor = bar.getAttribute('data-countdown-label-color') || ''; 587 var rawLabelDays = bar.getAttribute('data-countdown-label-days'); 588 var rawLabelHours = bar.getAttribute('data-countdown-label-hours'); 589 var rawLabelMinutes = bar.getAttribute('data-countdown-label-minutes'); 590 var rawLabelSeconds = bar.getAttribute('data-countdown-label-seconds'); 591 timerElements.forEach(function(timerEl) { 592 if (cdBgColor) timerEl.setAttribute('data-countdown-bg-color', cdBgColor); 593 if (cdTextColor) timerEl.setAttribute('data-countdown-text-color', cdTextColor); 594 if (cdLabelColor) timerEl.setAttribute('data-countdown-label-color', cdLabelColor); 595 // PHP sets style-specific defaults for new bars; '' means user explicitly cleared the label. 596 // Use !== null so '' (cleared) passes through rather than falling back to JS defaults. 597 var elStyle = timerEl.getAttribute('data-countdown-style') || 'simple'; 598 var defDays, defHours, defMins, defSecs; 599 if (elStyle === 'box' || elStyle === 'circular') { 600 defDays = 'Day'; defHours = 'Hour'; defMins = 'Minute'; defSecs = 'Second'; 601 } else if (elStyle === 'simple') { 602 defDays = 'd'; defHours = 'h'; defMins = 'm'; defSecs = 's'; 603 } else { 604 defDays = 'Days'; defHours = 'Hours'; defMins = 'Minutes'; defSecs = 'Seconds'; 605 } 606 timerEl.setAttribute('data-countdown-label-days', rawLabelDays !== null ? rawLabelDays : defDays); 607 timerEl.setAttribute('data-countdown-label-hours', rawLabelHours !== null ? rawLabelHours : defHours); 608 timerEl.setAttribute('data-countdown-label-minutes', rawLabelMinutes !== null ? rawLabelMinutes : defMins); 609 timerEl.setAttribute('data-countdown-label-seconds', rawLabelSeconds !== null ? rawLabelSeconds : defSecs); 610 if ((elStyle === 'simple' || elStyle === 'digital') && cdTextColor) { 611 timerEl.style.color = cdTextColor; 612 } 613 if (elStyle === 'digital' && cdBgColor) { 614 timerEl.style.backgroundColor = cdBgColor; 615 timerEl.style.padding = '4px 8px'; 616 timerEl.style.borderRadius = '4px'; 617 } 618 }); 619 620 // Expired action handler â runs once when the countdown reaches zero. 621 var expiredAction = bar.getAttribute('data-countdown-expired-action') || 'show_message'; 622 var expiredMessage = bar.getAttribute('data-countdown-expired-message') || ''; 623 var expiryHandled = false; 624 625 function handleExpiry() { 626 if (expiryHandled) return; 627 expiryHandled = true; 628 if (expiredAction === 'hide_bar') { 629 barWrapper.style.display = 'none'; 630 } else if (expiredAction === 'show_message') { 631 var safeMsg = expiredMessage 632 .replace(/&/g, '&') 633 .replace(/</g, '<') 634 .replace(/>/g, '>');
635 timerElements.forEach(function(el) { 636 el.innerHTML = '<span class="hashbar-countdown-expired-message">' + safeMsg + '</span>'; 637 }); 638 } 639 } 640 641 // Handle FIXED type 642 if (countdownType === 'fixed') { 643 if (!countdownDate) return; 644 645 // Build timer configs with display options for each timer element 646 const timerConfigs = []; 647 timerElements.forEach(function(timerElement) { 648 timerConfigs.push({ 649 element: timerElement, 650 style: timerElement.getAttribute('data-countdown-style') || 'simple', 651 showDays: timerElement.getAttribute('data-show-days') !== 'false', 652 showHours: timerElement.getAttribute('data-show-hours') !== 'false', 653 showMinutes: timerElement.getAttribute('data-show-minutes') !== 'false', 654 showSeconds: timerElement.getAttribute('data-show-seconds') !== 'false', 655 lastDisplayState: null 656 }); 657 }); 658 659 function updateFixedIfChanged() { 660 const timezone = timezoneStr || 'site'; 661 const timezoneOffset = getTimezoneOffset(timezone); 662 const now = new Date(); 663 const utcMs = now.getTime(); 664 665 // Parse the countdown date 666 const endDateParts = countdownDate.split('T')[0].split('-'); 667 const endYear = parseInt(endDateParts[0], 10); 668 const endMonth = parseInt(endDateParts[1], 10) - 1; 669 const endDate = parseInt(endDateParts[2], 10); 670 671 const timeParts = countdownDate.split('T')[1] || '00:00:00'; 672 const [endHours, endMinutes] = timeParts.split(':').map(Number); 673 674 let remaining; 675 676 if (timezone === 'visitor') { 677 // Selected end time only has minute resolution; expire at end of that minute (HH:MM:59.999). 678 const endTimeLocal = new Date(endYear, endMonth, endDate, endHours, endMinutes, 59, 999); 679 remaining = endTimeLocal.getTime() - utcMs; 680 } else { 681 // For site timezone or other timezones, calculate with offset 682 const targetOffsetMs = timezoneOffset * 60 * 60 * 1000; 683 const endTimeUtc = new Date(Date.UTC(endYear, endMonth, endDate, endHours, endMinutes, 59, 999)); 684 // The actual UTC time is: input time minus the timezone offset 685 const actualEndTimeUtc = endTimeUtc.getTime() - targetOffsetMs; 686 remaining = actualEndTimeUtc - utcMs; 687 } 688 689 // Handle expiry before any DOM update â prevents flash of 00:00 for already-expired bars 690 if (remaining < 0) { 691 handleExpiry(); 692 return; 693 } 694 695 // Update each timer element with its own display options 696 timerConfigs.forEach(function(config) { 697 updateCountdownOptimized(config, remaining, countdownType); 698 }); 699 } 700 701 // Call synchronously on load so expired bars are hidden before first paint 702 updateFixedIfChanged(); 703 var fixedInterval; 704 if (!expiryHandled) { 705 fixedInterval = setInterval(function() { 706 updateFixedIfChanged(); 707 if (expiryHandled) clearInterval(fixedInterval); 708 }, 1000); 709 } 710 } else if (countdownType === 'recurring') { 711 // Recurring countdown - resets at specific time each day 712 const resetTime = bar.getAttribute('data-countdown-reset-time') || '00:00'; 713 const resetDaysStr = bar.getAttribute('data-countdown-reset-days') || '[]'; 714 let resetDays = [];
715 try { 716 resetDays = JSON.parse(resetDaysStr); 717 } catch (e) { 718 resetDays = []; 719 } 720 721 const timerConfigs = []; 722 timerElements.forEach(function(timerElement) { 723 timerConfigs.push({ 724 element: timerElement, 725 style: timerElement.getAttribute('data-countdown-style') || 'simple', 726 showDays: timerElement.getAttribute('data-show-days') !== 'false', 727 showHours: timerElement.getAttribute('data-show-hours') !== 'false', 728 showMinutes: timerElement.getAttribute('data-show-minutes') !== 'false', 729 showSeconds: timerElement.getAttribute('data-show-seconds') !== 'false', 730 lastDisplayState: null 731 }); 732 }); 733 734 function updateRecurringTimers() { 735 const now = new Date().getTime(); 736 const timezone = timezoneStr || 'site'; 737 const timezoneOffset = getTimezoneOffset(timezone); 738 739 // If no active days set, show 0 740 if (resetDays.length === 0) { 741 timerConfigs.forEach(function(config) { 742 updateCountdownOptimized(config, 0, countdownType); 743 }); 744 return; 745 } 746 747 // Parse reset time 748 const [resetHours, resetMinutes] = resetTime.split(':').map(Number); 749 750 let distance; 751 752 if (timezone === 'visitor') { 753 // For visitor timezone, work directly in local time 754 const nowLocal = new Date(); 755 let nextReset = new Date(nowLocal); 756 nextReset.setHours(resetHours, resetMinutes, 0, 0); 757 758 // If reset time has already passed today, move to next day 759 if (nextReset <= nowLocal) { 760 nextReset.setDate(nextReset.getDate() + 1); 761 } 762 763 // Check if next reset is on an active day 764 let daysToCheck = 0; 765 while (daysToCheck < 7 && resetDays.indexOf(nextReset.getDay()) === -1) { 766 nextReset.setDate(nextReset.getDate() + 1); 767 daysToCheck++; 768 } 769 770 distance = nextReset.getTime() - now; 771 } else { 772 // For site timezone, work in UTC and apply offset 773 const targetOffsetMs = timezoneOffset * 60 * 60 * 1000; 774 775 // Get current UTC time 776 const nowUtc = now; 777 // Calculate current time in site timezone (as UTC timestamp) 778 const nowInSiteTz = nowUtc + targetOffsetMs; 779 const nowInSiteTzDate = new Date(nowInSiteTz); 780 781 // Create reset time for today in site timezone (using UTC methods to avoid local timezone interference) 782 let nextResetUtc = Date.UTC( 783 nowInSiteTzDate.getUTCFullYear(), 784 nowInSiteTzDate.getUTCMonth(), 785 nowInSiteTzDate.getUTCDate(), 786 resetHours, 787 resetMinutes, 788 0, 0 789 ); 790 791 // Convert reset time from site timezone to UTC 792 nextResetUtc = nextResetUtc - targetOffsetMs; 793 794 // If reset time has already passed, move to next day 795 if (nextResetUtc <= nowUtc) { 796 nextResetUtc += 24 * 60 * 60 * 1000; // Add 24 hours 797 } 798 799 // Check if next reset is on an active day (in site timezone) 800 let nextResetDate = new Date(nextResetUtc + targetOffsetMs); 801 let daysToCheck = 0; 802 while (daysToCheck < 7 && resetDays.indexOf(nextResetDate.getUTCDay()) === -1) { 803 nextResetUtc += 24 * 60 * 60 * 1000; // Add 24 hours 804 nextResetDate = new Date(nextResetUtc + targetOffsetMs); 805 daysToCheck++; 806 } 807 808 distance = nextResetUtc - nowUtc; 809 } 810 811 timerConfigs.forEach(function(config) { 812 updateCountdownOptimized(config, distance, countdownType); 813 }); 814 } 815 816 // Delay first update by 100ms to ensure all data is fully initialized 817 setTimeout(function() { 818 updateRecurringTimers(); 819 setInterval(updateRecurringTimers, 1000); 820 }, 100); 821 } else if (countdownType === 'evergreen') { 822 // Evergreen countdown - each visitor sees their own timer 823 const duration = parseInt(bar.getAttribute('data-countdown-duration'), 10) || 24; 824 825 const timerConfigs = [];
826 timerElements.forEach(function(timerElement) { 827 timerConfigs.push({ 828 element: timerElement, 829 style: timerElement.getAttribute('data-countdown-style') || 'simple', 830 showDays: timerElement.getAttribute('data-show-days') !== 'false', 831 showHours: timerElement.getAttribute('data-show-hours') !== 'false', 832 showMinutes: timerElement.getAttribute('data-show-minutes') !== 'false', 833 showSeconds: timerElement.getAttribute('data-show-seconds') !== 'false', 834 lastDisplayState: null, 835 sessionStartTime: null // Track when timer started for this session 836 }); 837 }); 838 839 function updateEvergreenTimers() { 840 const now = new Date().getTime(); 841 const durationMs = duration * 60 * 60 * 1000; 842 var allExpired = true; 843 844 timerConfigs.forEach(function(config) { 845 // Initialize session start time on first call 846 if (config.sessionStartTime === null) { 847 config.sessionStartTime = now; 848 } 849 850 // Calculate time remaining from session start 851 const elapsed = now - config.sessionStartTime; 852 const distance = durationMs - elapsed; 853 854 if (distance >= 0) allExpired = false; 855 }); 856 857 if (allExpired) { 858 handleExpiry(); 859 return; 860 } 861 862 timerConfigs.forEach(function(config) { 863 const elapsed = now - config.sessionStartTime; 864 const distance = durationMs - elapsed; 865 updateCountdownOptimized(config, distance, countdownType); 866 }); 867 } 868 869 // Call synchronously on load so expired bars are hidden before first paint 870 updateEvergreenTimers(); 871 var evergreenInterval; 872 if (!expiryHandled) { 873 evergreenInterval = setInterval(function() { 874 updateEvergreenTimers(); 875 if (expiryHandled) clearInterval(evergreenInterval); 876 }, 1000); 877 } 878 } 879 } 880 881 /** 882 * Update countdown display 883 */ 884 function updateCountdownOptimized(config, distance, countdownType) { 885 const timerElement = config.element; 886 const style = config.style; 887 const showDays = config.showDays; 888 const showHours = config.showHours; 889 const showMinutes = config.showMinutes; 890 const showSeconds = config.showSeconds; 891 892 // Calculate time units 893 const days = Math.floor(distance / (1000 * 60 * 60 * 24)); 894 const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); 895 const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); 896 const seconds = Math.floor((distance % (1000 * 60)) / 1000); 897 898 // Build the display text/state string for change detection 899 let displayState; 900 901 // Handle 'Ended' state only if countdown has finished 902 if (distance < 0) { 903 displayState = 'ENDED'; 904 } else if (style === 'simple') { 905 // Simple text format: "5d 3h 45m 30s" 906 const parts = []; 907 if (showDays && days > 0) parts.push(days + 'd'); 908 if (showHours && hours > 0) parts.push(hours + 'h'); 909 if (showMinutes && minutes > 0) parts.push(minutes + 'm'); 910 if (showSeconds) parts.push(seconds + 's'); 911 912 displayState = parts.length > 0 ? parts.join(' ') : '0s'; 913 } else if (style === 'digital') { 914 // Digital format: "05:03:45:30" 915 let displayText = ''; 916 if (showDays) displayText += padZero(days) + ':'; 917 if (showHours) displayText += padZero(hours) + ':'; 918 if (showMinutes) displayText += padZero(minutes) + ':'; 919 if (showSeconds) displayText += padZero(seconds); 920 921 displayState = displayText.replace(/:$/, ''); 922 } else if (style === 'circular' || style === 'box') { 923 // For circular/box, create a state string from the time values 924 displayState = (showDays ? days : '?') + '|' + (showHours ? hours : '?') + '|' + (showMinutes ? minutes : '?') + '|' + (showSeconds ? seconds : '?'); 925 } else if (countdownType === 'compact') { 926 // Legacy compact format 927 if (days > 0) { 928 displayState = days + 'd ' + hours + 'h'; 929 } else if (hours > 0) { 930 displayState = hours + 'h ' + minutes + 'm'; 931 } else { 932 displayState = minutes + 'm ' + seconds + 's'; 933 } 934 } else { 935 // Legacy detailed format 936 displayState = padZero(days) + ':' + padZero(hours) + ':' + padZero(minutes) + ':' + padZero(seconds); 937 } 938 939 // Check if display state has changed 940 if (config.lastDisplayState === displayState) { 941 return; // Nothing changed, skip DOM updates 942 } 943 944 // Store new state 945 config.lastDisplayState = displayState; 946 947 // NOW perform the actual DOM updates 948 if (distance < 0) { 949 // Only set textContent if this is NOT a box-style countdown 950 if (style !== 'circular' && style !== 'box') { 951 timerElement.textContent = 'Ended'; 952 } else { 953 // For box style, set all number boxes to show ended status 954 updateBoxCountdown(timerElement, 0, 0, 0, 0, showDays, showHours, showMinutes, showSeconds); 955 } 956 return; 957 } 958 959 if (style === 'simple') { 960 // Simple text format using custom labels (no space â label acts as suffix) 961 const _lDays = timerElement.getAttribute('data-countdown-label-days'); 962 const _lHours = timerElement.getAttribute('data-countdown-label-hours'); 963 const _lMinutes = timerElement.getAttribute('data-countdown-label-minutes'); 964 const _lSeconds = timerElement.getAttribute('data-countdown-label-seconds'); 965 const lDays = _lDays !== null ? _lDays : 'd'; 966 const lHours = _lHours !== null ? _lHours : 'h'; 967 const lMinutes = _lMinutes !== null ? _lMinutes : 'm'; 968 const lSeconds = _lSeconds !== null ? _lSeconds : 's'; 969 const parts = []; 970 if (showDays && days > 0) parts.push(days + lDays); 971 if (showHours && hours > 0) parts.push(hours + lHours); 972 if (showMinutes && minutes > 0) parts.push(minutes + lMinutes); 973 if (showSeconds) parts.push(seconds + lSeconds); 974 975 timerElement.textContent = parts.length > 0 ? parts.join(' ') : '0' + lSeconds; 976 } else if (style === 'digital') { 977 // Digital format: "05:03:45:30" 978 let displayText = ''; 979 if (showDays) displayText += padZero(days) + ':'; 980 if (showHours) displayText += padZero(hours) + ':'; 981 if (showMinutes) displayText += padZero(minutes) + ':'; 982 if (showSeconds) displayText += padZero(seconds); 983 984 // Remove trailing colon if no seconds are shown 985 timerElement.textContent = displayText.replace(/:$/, ''); 986 } else if (style === 'circular' || style === 'box') { 987 // Circular or box format - update boxes only (NEVER set textContent on wrapper) 988 updateBoxCountdown(timerElement, days, hours, minutes, seconds, showDays, showHours, showMinutes, showSeconds); 989 } else if (countdownType === 'compact') { 990 // Legacy compact format 991 if (days > 0) { 992 timerElement.textContent = days + 'd ' + hours + 'h'; 993 } else if (hours > 0) { 994 timerElement.textContent = hours + 'h ' + minutes + 'm'; 995 } else { 996 timerElement.textContent = minutes + 'm ' + seconds + 's'; 997 } 998 } else { 999 // Legacy detailed format 1000 timerElement.textContent = padZero(days) + ':' + padZero(hours) + ':' + padZero(minutes) + ':' + padZero(seconds); 1001 } 1002 } 1003 1004 /** 1005 * Update circular countdown display 1006 */ 1007 function updateCircularCountdown(timerElement, days, hours, minutes, seconds, showDays, showHours, showMinutes, showSeconds, countdownDate) { 1008 const circle = timerElement.querySelector('.countdown-circle-progress'); 1009 const textElement = timerElement.querySelector('.countdown-circle-text'); 1010 1011 if (!circle || !textElement) { 1012 return; 1013 } 1014 1015 // Calculate total time and elapsed time 1016 const endDate = new Date(countdownDate).getTime(); 1017 const now = new Date().getTime(); 1018 const startDate = new Date(countdownDate.split('T')[0] + 'T00:00').getTime(); 1019 1020 const totalTime = endDate - startDate; 1021 const timeLeft = endDate - now; 1022 const percentage = Math.max(0, (timeLeft / totalTime) * 100); 1023 1024 // Update circle progress (circumference of circle with radius 45 is 2 * Ï * 45 â 282.7) 1025 const circumference = 282.7; 1026 const dashOffset = circumference - (percentage / 100) * circumference; 1027 1028 circle.style.strokeDashoffset = dashOffset; 1029 1030 // Update text based on display options 1031 let displayText = ''; 1032 if (showDays && days > 0) { 1033 displayText = days + 'd'; 1034 } else if (showHours && hours > 0) { 1035 displayText = hours + 'h'; 1036 } else if (showMinutes && minutes > 0) { 1037 displayText = minutes + 'm'; 1038 } else if (showSeconds) { 1039 displayText = seconds + 's'; 1040 } 1041 1042 textElement.textContent = displayText || '0s'; 1043 } 1044 1045 /** 1046 * Update box-style or circular countdown display 1047 */ 1048 function updateBoxCountdown(timerElement, days, hours, minutes, seconds, showDays, showHours, showMinutes, showSeconds) { 1049 const isCircular = timerElement.querySelector('.hb-countdown-circular') !== null; 1050 const bgColor = timerElement.getAttribute('data-countdown-bg-color') || ''; 1051 const textColor = timerElement.getAttribute('data-countdown-text-color') || ''; 1052 const labelColor = timerElement.getAttribute('data-countdown-label-color') || ''; 1053 const _rawDays = timerElement.getAttribute('data-countdown-label-days'); 1054 const _rawHours = timerElement.getAttribute('data-countdown-label-hours'); 1055 const _rawMins = timerElement.getAttribute('data-countdown-label-minutes'); 1056 const _rawSecs = timerElement.getAttribute('data-countdown-label-seconds'); 1057 const labelDays = _rawDays !== null ? _rawDays : 'Day'; 1058 const labelHours = _rawHours !== null ? _rawHours : 'Hour'; 1059 const labelMins = _rawMins !== null ? _rawMins : 'Minute';
1060 const labelSecs = _rawSecs !== null ? _rawSecs : 'Second'; 1061 1062 function applyColors(wrapper) { 1063 if (bgColor) { 1064 wrapper.style.backgroundColor = bgColor; 1065 wrapper.style.borderColor = bgColor; 1066 } 1067 if (textColor) { 1068 const num = wrapper.querySelector('.countdown-number'); 1069 if (num) num.style.color = textColor; 1070 } 1071 if (labelColor) { 1072 const lbl = wrapper.querySelector('.countdown-label'); 1073 if (lbl) lbl.style.color = labelColor; 1074 } 1075 } 1076 1077 // Update days 1078 if (showDays) { 1079 let daysWrapper = timerElement.querySelector('.hb-countdown-days'); 1080 if (!daysWrapper) { 1081 daysWrapper = document.createElement('div'); 1082 if (isCircular) { 1083 daysWrapper.className = 'hb-countdown-unit hb-countdown-days hb-countdown-circular'; 1084 daysWrapper.innerHTML = '<div class="countdown-number">00</div><div class="countdown-label">' + labelDays + '</div>'; 1085 } else { 1086 daysWrapper.className = 'hb-countdown-unit hb-countdown-days'; 1087 daysWrapper.innerHTML = '<div class="hb-countdown-box"><div class="countdown-number">00</div></div><div class="countdown-label">' + labelDays + '</div>'; 1088 } 1089 timerElement.appendChild(daysWrapper); 1090 } 1091 const numberDiv = daysWrapper.querySelector('.countdown-number'); 1092 if (numberDiv) { 1093 const newText = String(days).padStart(2, '0'); 1094 if (numberDiv.textContent !== newText) numberDiv.textContent = newText; 1095 } 1096 const lblDiv = daysWrapper.querySelector('.countdown-label'); 1097 if (lblDiv && lblDiv.textContent !== labelDays) lblDiv.textContent = labelDays; 1098 applyColors(daysWrapper); 1099 } 1100 1101 // Update hours 1102 if (showHours) { 1103 let hoursWrapper = timerElement.querySelector('.hb-countdown-hours'); 1104 if (!hoursWrapper) { 1105 hoursWrapper = document.createElement('div'); 1106 if (isCircular) { 1107 hoursWrapper.className = 'hb-countdown-unit hb-countdown-hours hb-countdown-circular'; 1108 hoursWrapper.innerHTML = '<div class="countdown-number">00</div><div class="countdown-label">' + labelHours + '</div>'; 1109 } else { 1110 hoursWrapper.className = 'hb-countdown-unit hb-countdown-hours'; 1111 hoursWrapper.innerHTML = '<div class="hb-countdown-box"><div class="countdown-number">00</div></div><div class="countdown-label">' + labelHours + '</div>'; 1112 } 1113 timerElement.appendChild(hoursWrapper); 1114 } 1115 const numberDiv = hoursWrapper.querySelector('.countdown-number'); 1116 if (numberDiv) { 1117 const newText = String(hours).padStart(2, '0'); 1118 if (numberDiv.textContent !== newText) numberDiv.textContent = newText; 1119 } 1120 const lblDiv = hoursWrapper.querySelector('.countdown-label'); 1121 if (lblDiv && lblDiv.textContent !== labelHours) lblDiv.textContent = labelHours; 1122 applyColors(hoursWrapper); 1123 } 1124 1125 // Update minutes 1126 if (showMinutes) { 1127 let minutesWrapper = timerElement.querySelector('.hb-countdown-minutes'); 1128 if (!minutesWrapper) { 1129 minutesWrapper = document.createElement('div'); 1130 if (isCircular) { 1131 minutesWrapper.className = 'hb-countdown-unit hb-countdown-minutes hb-countdown-circular'; 1132 minutesWrapper.innerHTML = '<div class="countdown-number">00</div><div class="countdown-label">' + labelMins + '</div>'; 1133 } else { 1134 minutesWrapper.className = 'hb-countdown-unit hb-countdown-minutes'; 1135 minutesWrapper.innerHTML = '<div class="hb-countdown-box"><div class="countdown-number">00</div></div><div class="countdown-label">' + labelMins + '</div>'; 1136 } 1137 timerElement.appendChild(minutesWrapper); 1138 } 1139 const numberDiv = minutesWrapper.querySelector('.countdown-number'); 1140 if (numberDiv) { 1141 const newText = String(minutes).padStart(2, '0'); 1142 if (numberDiv.textContent !== newText) numberDiv.textContent = newText; 1143 } 1144 const lblDiv = minutesWrapper.querySelector('.countdown-label'); 1145 if (lblDiv && lblDiv.textContent !== labelMins) lblDiv.textContent = labelMins;
1146 applyColors(minutesWrapper); 1147 } 1148 1149 // Update seconds 1150 if (showSeconds) { 1151 let secondsWrapper = timerElement.querySelector('.hb-countdown-seconds'); 1152 if (!secondsWrapper) { 1153 secondsWrapper = document.createElement('div'); 1154 if (isCircular) { 1155 secondsWrapper.className = 'hb-countdown-unit hb-countdown-seconds hb-countdown-circular'; 1156 secondsWrapper.innerHTML = '<div class="countdown-number">00</div><div class="countdown-label">' + labelSecs + '</div>'; 1157 } else { 1158 secondsWrapper.className = 'hb-countdown-unit hb-countdown-seconds'; 1159 secondsWrapper.innerHTML = '<div class="hb-countdown-box"><div class="countdown-number">00</div></div><div class="countdown-label">' + labelSecs + '</div>'; 1160 } 1161 timerElement.appendChild(secondsWrapper); 1162 } 1163 const numberDiv = secondsWrapper.querySelector('.countdown-number'); 1164 if (numberDiv) { 1165 const newText = String(seconds).padStart(2, '0'); 1166 if (numberDiv.textContent !== newText) numberDiv.textContent = newText; 1167 } 1168 const lblDiv = secondsWrapper.querySelector('.countdown-label'); 1169 if (lblDiv && lblDiv.textContent !== labelSecs) lblDiv.textContent = labelSecs; 1170 applyColors(secondsWrapper); 1171 } 1172 } 1173 1174 /** 1175 * Pad number with leading zero 1176 */ 1177 function padZero(num) { 1178 return (num < 10 ? '0' : '') + num; 1179 } 1180 1181 /** 1182 * Initialize coupon copy functionality 1183 */ 1184 function initializeCouponCopy(bar, barId) { 1185 1186 const couponDisplay = bar.querySelector('.hashbar-coupon-display'); 1187 1188 if (couponDisplay) { 1189 // Get coupon code from display element or bar element 1190 const couponCode = couponDisplay.getAttribute('data-coupon-code') || bar.getAttribute('data-coupon-code'); 1191 1192 // Get custom button texts from data attributes 1193 const copiedButtonText = couponDisplay.getAttribute('data-copied-text') || 'Copied!'; 1194 const autocopyOnClick = couponDisplay.getAttribute('data-autocopy-on-click') === 'true'; 1195 1196 if (couponCode) { 1197 const codeElement = couponDisplay.querySelector('code'); 1198 const copyButton = couponDisplay.querySelector('.hashbar-coupon-copy'); 1199 1200 1201 // Helper function to show copy feedback on code element 1202 function showCodeFeedback() { 1203 if (codeElement) { 1204 const originalText = codeElement.textContent; 1205 codeElement.textContent = copiedButtonText; 1206 setTimeout(function() { 1207 codeElement.textContent = originalText; 1208 }, 2000); 1209 } 1210 } 1211 1212 // Helper function to show copy feedback on button 1213 function showButtonFeedback() { 1214 if (copyButton) { 1215 const originalText = copyButton.textContent; 1216 copyButton.textContent = copiedButtonText; 1217 setTimeout(function() { 1218 copyButton.textContent = originalText; 1219 }, 2000); 1220 } 1221 } 1222 1223 // Make coupon code clickable to copy (if autocopy is enabled) 1224 if (codeElement && autocopyOnClick) { 1225 codeElement.style.cursor = 'pointer'; 1226 codeElement.title = 'Click to copy'; 1227 1228 codeElement.addEventListener('click', function(e) { 1229 e.preventDefault(); 1230 e.stopPropagation(); 1231 copyToClipboard(couponCode); 1232 showCodeFeedback(); 1233 1234 // Track coupon copy for A/B testing 1235 if (bar.getAttribute('data-ab-test-enabled') === 'true') { 1236 const abTestVariantId = bar.getAttribute('data-ab-test-variant-id'); 1237 trackABTestEvent(barId, abTestVariantId, 'coupon_copy', couponCode); 1238 } 1239 }); 1240 } 1241 1242 // Make copy button clickable 1243 if (copyButton) { 1244 copyButton.style.cursor = 'pointer'; 1245 1246 copyButton.addEventListener('click', function(e) { 1247 e.preventDefault(); 1248 e.stopPropagation(); 1249 copyToClipboard(couponCode); 1250 showButtonFeedback(); 1251 1252 // Track coupon copy for A/B testing 1253 if (bar.getAttribute('data-ab-test-enabled') === 'true') { 1254 const abTestVariantId = bar.getAttribute('data-ab-test-variant-id'); 1255 trackABTestEvent(barId, abTestVariantId, 'coupon_copy', couponCode); 1256 } 1257 }); 1258 1259 // Hover effect for copy button 1260 copyButton.addEventListener('mouseover', function() { 1261 copyButton.style.opacity = '1'; 1262 }); 1263 1264 copyButton.addEventListener('mouseout', function() { 1265 copyButton.style.opacity = '0.7'; 1266 }); 1267 } 1268 } else { 1269 } 1270 } 1271 1272 // Handle inline coupon code click (when countdown is inline) 1273 const inlineCoupon = bar.querySelector('.hashbar-coupon-inline'); 1274 if (inlineCoupon) { 1275 const inlineCouponCode = inlineCoupon.getAttribute('data-coupon-code'); 1276 const inlineCopiedText = inlineCoupon.getAttribute('data-copied-text') || 'Copied!'; 1277 const inlineAutocopyOnClick = inlineCoupon.getAttribute('data-autocopy-on-cl
1277ick') === 'true'; 1278 1279 // Only add click handler if autocopy on click is enabled 1280 if (inlineAutocopyOnClick) { 1281 inlineCoupon.addEventListener('click', function(e) { 1282 e.preventDefault(); 1283 e.stopPropagation(); 1284 1285 // Perform the copy 1286 copyToClipboard(inlineCouponCode); 1287 1288 // Track coupon copy for A/B testing 1289 if (bar.getAttribute('data-ab-test-enabled') === 'true') { 1290 const abTestVariantId = bar.getAttribute('data-ab-test-variant-id'); 1291 trackABTestEvent(barId, abTestVariantId, 'coupon_copy', inlineCouponCode); 1292 } 1293 1294 // Store original HTML to restore after feedback 1295 const originalHTML = inlineCoupon.innerHTML; 1296 1297 // Show feedback 1298 inlineCoupon.textContent = inlineCopiedText; 1299 inlineCoupon.style.opacity = '0.7'; 1300 1301 // Restore after delay 1302 setTimeout(function() { 1303 inlineCoupon.innerHTML = originalHTML; 1304 inlineCoupon.style.opacity = ''; 1305 }, 2000); 1306 }); 1307 1308 // Set cursor to pointer when autocopy is enabled 1309 inlineCoupon.style.cursor = 'pointer'; 1310 inlineCoupon.title = 'Click to copy'; 1311 } 1312 1313 // Also make the span selectable with better touch support 1314 inlineCoupon.style.userSelect = 'none'; 1315 } 1316 } 1317 1318 /** 1319 * Initialize CTA button with first message content 1320 */ 1321 function initializeCTAButton(bar, barId) { 1322 const messagesData = bar.getAttribute('data-messages'); 1323 const ctaButton = bar.querySelector('.hashbar-announcement-cta'); 1324 1325 if (!ctaButton) { 1326 return; 1327 } 1328 1329 // If no messages data, try to use legacy CTA fields 1330 if (!messagesData) { 1331 const ctaText = bar.getAttribute('data-cta-text'); 1332 const ctaUrl = bar.getAttribute('data-cta-url'); 1333 const ctaTarget = bar.getAttribute('data-cta-target'); 1334 const ctaEnabled = bar.getAttribute('data-cta-enabled'); 1335 1336 if (ctaText) { 1337 ctaButton.textContent = ctaText; 1338 } 1339 if (ctaUrl) { 1340 ctaButton.href = ctaUrl; 1341 } 1342 if (ctaTarget) { 1343 ctaButton.target = ctaTarget; 1344 } 1345 ctaButton.style.display = ctaEnabled === 'false' ? 'none' : 'inline-block'; 1346 return; 1347 } 1348 1349 let messages = []; 1350 try { 1351 messages = JSON.parse(messagesData); 1352 } catch (e) { 1353 return; 1354 } 1355 1356 if (!Array.isArray(messages) || messages.length === 0) { 1357 return; 1358 } 1359 1360 // Initialize button with first message content 1361 const firstMessage = messages[0]; 1362 1363 if (firstMessage.cta_text) { 1364 ctaButton.textContent = firstMessage.cta_text; 1365 } 1366 1367 if (firstMessage.cta_url) { 1368 ctaButton.href = firstMessage.cta_url; 1369 } 1370 1371 if (firstMessage.cta_target) { 1372 ctaButton.target = firstMessage.cta_target; 1373 } 1374 1375 // Show/hide button based on cta_enabled flag 1376 ctaButton.style.display = firstMessage.cta_enabled !== false ? 'inline-block' : 'none'; 1377 } 1378 1379 /** 1380 * Initialize message rotation 1381 */ 1382 function initializeMessageRotation(bar, barId) { 1383 const messagesData = bar.getAttribute('data-messages'); 1384 const rotationInterval = parseInt(bar.getAttribute('data-message-rotation-interval'), 10) || 5; 1385 1386 if (!messagesData) { 1387 return; 1388 } 1389 1390 let messages = []; 1391 try { 1392 messages = JSON.parse(messagesData); 1393 } catch (e) { 1394 return; 1395 } 1396 1397 if (!Array.isArray(messages) || messages.length <= 1) { 1398 return; 1399 } 1400 1401 let currentIndex = 0; 1402 const contentElement = bar.querySelector('.hashbar-announcement-content'); 1403 const messageElement = contentElement ? contentElement.querySelector('p') : null; 1404 const ctaButton = bar.querySelector('.hashbar-announcement-cta'); 1405 1406 if (!messageElement) { 1407 return; 1408 } 1409 1410 // Setup interval to rotate messages (only if there are multiple messages) 1411 if (messages.length > 1) { 1412 let lastMessageText = messages[0] ? messages[0].text : ''; 1413 1414 setInterval(function() { 1415 currentIndex = (currentIndex + 1) % messages.length; 1416 const currentMessage = messages[currentIndex]; 1417 1418 if (currentMessage && currentMessage.text && currentMessage.text !== lastMessageText) { 1419 // CRITICAL: Preserve countdown timer when rotating messages 1420 // The countdown timer can be in 4 positions: before, inline, after, below 1421 // Only 'inline' position timers are inside the message element and get destroyed 1422 1423 // Find inline countdown timers (those inside the message element) 1424 const inlineTimers = Array.from(messageElement.querySelectorAll('.hashbar-countdown-timer-wrapper, .hashbar-countdown-timer-text')); 1425 1426 // Store references to the original timer elements 1427 // We'll reuse them instead of cloning to preserve the complete wrapper structure 1428 const originalTimers = inlineTimers.map(function(timerElement) { 1429 // Create a wrapper for each timer so we can easily find and restore it 1430 const wrapper = document.createElement('div'); 1431 wrapper.setAttribute('data-timer-placeholder', 'true'); 1432 timerElement.parentNode.insertBefore(wrapper, timerElement); 1433 wrapper.appendChild(timerElement); 1434 return wrapper; 1435 }); 1436 1437 // Update message text 1438 messageElement.innerHTML = currentMessage.text; 1439 lastMessageText = currentMessage.text; 1440 1441 // Restore the original timer elements to the message 1442 // Check if the message contains {countdown} placeholder 1443 if (currentMessage.text && currentMessage.text.includes('{countdown}')) { 1444 // Replace the {countdown} text with the actual timer element 1445 const textNodes = Array.from(messageElement.childNodes).filter(function(node) { 1446 return node.nodeType === Node.TEXT_NODE && node.textContent.includes('{countdown}'); 1447 }); 1448 1449 if (textNodes.length > 0) { 1450 // Replace the placeholder text node with the timer 1451 const placeholderNode = textNodes[0]; 1452 const beforeText = placeholderNode.textContent.split('{countdown}')[0]; 1453 const afterText = placeholderNode.textContent.split('{countdown}')[1]; 1454 1455 if (beforeText) { 1456 placeholderNode.textContent = beforeText; 1457 } else { 1458 placeholderNode.remove(); 1459 } 1460 1461 // Insert timer after the "before" text
1462 originalTimers.forEach(function(timerWrapper) { 1463 const timer = timerWrapper.querySelector('.hashbar-countdown-timer-wrapper, .hashbar-countdown-timer-text'); 1464 if (timer) { 1465 timerWrapper.parentNode.removeChild(timerWrapper); 1466 placeholderNode.parentNode.insertBefore(timer, placeholderNode.nextSibling); 1467 } 1468 }); 1469 1470 // Add the "after" text 1471 if (afterText) { 1472 const afterNode = document.createTextNode(afterText); 1473 placeholderNode.parentNode.insertBefore(afterNode, placeholderNode.nextSibling); 1474 } 1475 } 1476 } else { 1477 // If no placeholder, just append the timers at the end 1478 originalTimers.forEach(function(timerWrapper) { 1479 const timer = timerWrapper.querySelector('.hashbar-countdown-timer-wrapper, .hashbar-countdown-timer-text'); 1480 if (timer && timer.parentNode) { 1481 timer.parentNode.removeChild(timer); 1482 messageElement.appendChild(timer); 1483 } 1484 }); 1485 } 1486 1487 // Update CTA button if it exists and per-message CTA is configured 1488 if (ctaButton && currentMessage.cta_text) { 1489 ctaButton.textContent = currentMessage.cta_text; 1490 1491 // Update CTA URL if different per message 1492 if (currentMessage.cta_url) { 1493 ctaButton.href = currentMessage.cta_url; 1494 } 1495 1496 // Update CTA target if different per message 1497 if (currentMessage.cta_target) { 1498 ctaButton.target = currentMessage.cta_target; 1499 } 1500 1501 // Show/hide button based on cta_enabled flag 1502 ctaButton.style.display = currentMessage.cta_enabled !== false ? 'inline-block' : 'none'; 1503 } 1504 } 1505 }, rotationInterval * 1000); 1506 } 1507 } 1508 1509 /** 1510 * Copy text to clipboard 1511 */ 1512 function copyToClipboard(text) { 1513 // Modern browsers 1514 if (navigator.clipboard && window.isSecureContext) { 1515 navigator.clipboard.writeText(text).then(function() { 1516 // Success 1517 }).catch(function(err) { 1518 fallbackCopyToClipboard(text); 1519 }); 1520 } else { 1521 fallbackCopyToClipboard(text); 1522 } 1523 } 1524 1525 /** 1526 * Fallback copy to clipboard for older browsers 1527 */ 1528 function fallbackCopyToClipboard(text) { 1529 const textarea = document.createElement('textarea'); 1530 textarea.value = text; 1531 textarea.style.position = 'fixed'; 1532 textarea.style.opacity = '0'; 1533 document.body.appendChild(textarea); 1534 1535 try { 1536 textarea.select(); 1537 document.execCommand('copy'); 1538 } finally { 1539 document.body.removeChild(textarea); 1540 } 1541 } 1542 1543 /** 1544 * Setup exit animation trigger 1545 */ 1546 function setupExitAnimation(bar) { 1547 // Exit animation is applied when bar is closed 1548 // This is handled in the closeAnnouncement function 1549 } 1550 1551 /** 1552 * Track A/B test variant impression and clicks 1553 */ 1554 function trackABTestVariant(bar, barId) { 1555 // Use provided bar element or find it 1556 if (!bar) { 1557 bar = document.querySelector('.hashbar-announcement-bar[data-bar-id="' + barId + '"]'); 1558 } 1559 if (!bar) { 1560 return; 1561 } 1562 1563 // Get variant ID from data attribute (set by backend) 1564 const variantId = bar.getAttribute('data-ab-test-variant-id'); 1565 1566 if (!variantId) { 1567 // Not an A/B test variant - could be control variant 1568 // Control variant doesn't have variant_id, so we use 'control' or empty string 1569 const testEnabled = bar.getAttribute('data-ab-test-enabled'); 1570 if (testEnabled === 'true') { 1571 // Track control variant with 'control' as variant_id 1572 trackABTestEvent(barId, 'control', 'impression'); 1573 } 1574 return; 1575 } 1576 1577 // Track impression for test variant 1578 trackABTestEvent(barId, variantId, 'impression'); 1579 1580 // Track CTA button clicks 1581 const ctaButton = bar.querySelector('.hashbar-announcement-cta'); 1582 if (ctaButton) { 1583 ctaButton.addEventListener('click', function(e) { 1584 trackABTestEvent(barId, variantId, 'click', ctaButton.href || ctaButton.getAttribute('href')); 1585 }); 1586 } 1587 1588 // Track coupon copy as conversion 1589 const couponElements = bar.querySelectorAll('.hashbar-coupon-inline, .hashbar-coupon-display');
1590 couponElements.forEach(function(element) { 1591 element.addEventListener('click', function(e) { 1592 trackABTestEvent(barId, variantId, 'conversion', 'coupon_copy'); 1593 }); 1594 }); 1595 } 1596 1597 /** 1598 * Track A/B test event via REST API 1599 */ 1600 function trackABTestEvent(barId, variantId, eventType, eventValue) { 1601 // Get REST API URL and nonce from localized data 1602 const restUrl = (window.HashbarAnnouncementData && window.HashbarAnnouncementData.restUrl) || '/wp-json/hashbar/v1/'; 1603 const nonce = (window.HashbarAnnouncementData && window.HashbarAnnouncementData.nonce) || ''; 1604 1605 // Prevent duplicate tracking within the same page load 1606 // Use a simple key that tracks what we've already sent in this page view 1607 const trackingKey = 'hashbar_ab_tracked_' + barId + '_' + variantId + '_' + eventType; 1608 1609 if (sessionStorage.getItem(trackingKey) === 'true') { 1610 // Already tracked in this page view 1611 return; 1612 } 1613 1614 sessionStorage.setItem(trackingKey, 'true'); 1615 1616 // Send tracking request 1617 const payload = { 1618 bar_id: barId, 1619 variant_id: variantId, 1620 event_type: eventType, 1621 }; 1622 1623 // Only include event_value if it has a value 1624 if (eventValue) { 1625 payload.event_value = eventValue; 1626 } 1627 1628 fetch(restUrl + 'ab-test/track', { 1629 method: 'POST', 1630 headers: { 1631 'Content-Type': 'application/json', 1632 'X-WP-Nonce': nonce, 1633 }, 1634 body: JSON.stringify(payload), 1635 }) 1636 .then(function(response) { 1637 return response.json().catch(() => ({})); 1638 }) 1639 .then(function(data) { 1640 // Tracking completed 1641 }) 1642 .catch(function(error) { 1643 // Silently handle tracking errors 1644 }); 1645 } 1646 1647 /** 1648 * Cookie utilities 1649 */ 1650 function setCookie(name, value, days) { 1651 let expires = ''; 1652 1653 if (days) { 1654 const date = new Date(); 1655 date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 1656 expires = '; expires=' + date.toUTCString(); 1657 } 1658 1659 document.cookie = name + '=' + (value || '') + expires + '; path=/'; 1660 } 1661 1662 function getCookie(name) { 1663 const nameEQ = name + '='; 1664 const cookies = document.cookie.split(';'); 1665 1666 for (let i = 0; i < cookies.length; i++) { 1667 let cookie = cookies[i].trim(); 1668 1669 if (cookie.indexOf(nameEQ) === 0) { 1670 return cookie.substring(nameEQ.length); 1671 } 1672 } 1673 1674 return null; 1675 } 1676 1677 /** 1678 * Get timezone offset in hours 1679 */ 1680 function getTimezoneOffset(timezone) { 1681 // Handle new simplified timezone options 1682 if (timezone === 'site') { 1683 // Use the site timezone offset passed from PHP 1684 return typeof HashbarAnnouncementData !== 'undefined' && HashbarAnnouncementData.siteTimezoneOffset !== undefined 1685 ? parseFloat(HashbarAnnouncementData.siteTimezoneOffset) 1686 : 0; 1687 } 1688 1689 if (timezone === 'visitor') { 1690 // Use visitor's local timezone (browser timezone) 1691 // getTimezoneOffset() returns minutes with reversed sign, so we negate and convert to hours 1692 return -(new Date().getTimezoneOffset() / 60); 1693 } 1694 1695 // Legacy timezone abbreviations (for backward compatibility) 1696 const timezoneMap = { 1697 // UTC & GMT 1698 'UTC': 0, 1699 'GMT': 0, 1700 1701 // North America 1702 'EST': -5, 1703 'EDT': -4, 1704 'CST': -6, 1705 'CDT': -5, 1706 'MST': -7, 1707 'MDT': -6, 1708 'PST': -8, 1709 'PDT': -7, 1710 'AKST': -9, 1711 'AKDT': -8, 1712 'HST': -10, 1713 'HADT': -9, 1714 1715 // Europe 1716 'CET': 1, 1717 'CEST': 2, 1718 'EET': 2, 1719 'EEST': 3, 1720 'WET': 0, 1721 'WEST': 1, 1722 1723 // Asia 1724 'IST': 5.5, 1725 'IDT': 6.5, 1726 'JST': 9, 1727 'HKT': 8, 1728 'MYT': 8, 1729 'PHT': 8, 1730 'KST': 9, 1731 'WITA': 8, 1732 'WIB': 7, 1733 'ICT': 7, 1734 'BDT': 6, 1735 'PKT': 5, 1736 1737 // Middle East & Africa 1738 'GST': 4, 1739 'EAT': 3, 1740 'CAT': 2, 1741 'WAT': 1, 1742 'SAST': 2, 1743 1744 // Oceania 1745 'AEST': 10, 1746 'AEDT': 11, 1747 'ACST': 9.5, 1748 'ACDT': 10.5, 1749 'AWST': 8, 1750 'NZST': 12, 1751 'NZDT': 13, 1752 1753 // South America 1754 'ART': -3, 1755 'BRT': -3, 1756 'VET': -4, 1757 'CLT': -3, 1758 'PET': -5, 1759 }; 1760 1761 return timezoneMap[timezone] !== undefined ? timezoneMap[timezone] : 0; 1762 } 1763 1764 /** 1765 * Update countdown display for all timer styles 1766 */ 1767 function updateCountdownDisplay(timerElement, displayText) { 1768 const style = timerElement.getAttribute('data-countdown-style') || 'simple'; 1769 1770 if (style === 'simple' || style === 'digital') { 1771 // Simple and digital styles: update text content directly 1772 if (timerElement.textContent !== displayText) { 1773 timerElement.textContent = displayText; 1774 } 1775 } else if (style === 'circular' || style === 'box') { 1776 // Circular and box styles: update individual countdown-number elements 1777 if (displayText === 'Expired') { 1778 if (timerElement.textContent !== displayText) { 1779 timerElement.textContent = displayText; 1780 } 1781 return; 1782 } 1783 1784 // Parse the display text to extract days, hours, minutes, seconds 1785 // Format: "1d 2h 3m 4s" or variations like "1d 2h" or "2h 3m 4s" 1786 const parts = displayText.split(/\s+/); 1787 const values = {}; 1788
1789 parts.forEach(function(part) { 1790 if (part.endsWith('d')) { 1791 values.days = part.slice(0, -1); 1792 } else if (part.endsWith('h')) { 1793 values.hours = part.slice(0, -1); 1794 } else if (part.endsWith('m')) { 1795 values.minutes = part.slice(0, -1); 1796 } else if (part.endsWith('s')) { 1797 values.seconds = part.slice(0, -1); 1798 } 1799 }); 1800 1801 // Update the countdown-number divs - only if changed 1802 const daysUnit = timerElement.querySelector('.hb-countdown-days'); 1803 if (daysUnit) { 1804 const dayNumber = daysUnit.querySelector('.countdown-number'); 1805 const newDayText = String(values.days || 0).padStart(2, '0'); 1806 if (dayNumber && dayNumber.textContent !== newDayText) { 1807 dayNumber.textContent = newDayText; 1808 } 1809 } 1810 1811 const hoursUnit = timerElement.querySelector('.hb-countdown-hours'); 1812 if (hoursUnit) { 1813 const hourNumber = hoursUnit.querySelector('.countdown-number'); 1814 const newHourText = String(values.hours || 0).padStart(2, '0'); 1815 if (hourNumber && hourNumber.textContent !== newHourText) { 1816 hourNumber.textContent = newHourText; 1817 } 1818 } 1819 1820 const minutesUnit = timerElement.querySelector('.hb-countdown-minutes'); 1821 if (minutesUnit) { 1822 const minuteNumber = minutesUnit.querySelector('.countdown-number'); 1823 const newMinuteText = String(values.minutes || 0).padStart(2, '0'); 1824 if (minuteNumber && minuteNumber.textContent !== newMinuteText) { 1825 minuteNumber.textContent = newMinuteText; 1826 } 1827 } 1828 1829 const secondsUnit = timerElement.querySelector('.hb-countdown-seconds'); 1830 if (secondsUnit) { 1831 const secondNumber = secondsUnit.querySelector('.countdown-number'); 1832 const newSecondText = String(values.seconds || 0).padStart(2, '0'); 1833 if (secondNumber && secondNumber.textContent !== newSecondText) { 1834 secondNumber.textContent = newSecondText; 1835 } 1836 } 1837 } 1838 } 1839 1840})();
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.