PageSourceSearch

https://oklahomacontemporary.org/wp-content/plugins/hashbar-pro/assets/js/announcement-bar-pro.js

js oklahomacontemporary.org collected 2026-10-02 03:42:36 UTC 68,347 bytes, 1,840 lines download raw bytes

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, '&amp;')
633          .replace(/</g, '&lt;')
634          .replace(/>/g, '&gt;');
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.