countdown-8.x-1.8/js/countdown.integration.js

js/countdown.integration.js
/**
 * @file
 * Main integration script for countdown timers.
 *
 * Manages initialization, library loading, and lifecycle of countdown
 * instances. Respects per-element configuration over global settings.
 */

(function (Drupal, drupalSettings, once) {
  'use strict';

  /**
   * Countdown integration manager.
   *
   * Uses Object.assign to safely merge with any existing instance.
   *
   * @namespace
   */
  Drupal.countdown = Object.assign(Drupal.countdown || {}, {
    /**
     * Track countdown instances per element.
     *
     * @type {WeakMap}
     */
    instances: Drupal.countdown && Drupal.countdown.instances || new WeakMap(),

    /**
     * Track loaded libraries to prevent duplicate loading.
     *
     * @type {Set}
     */
    loadedLibraries: Drupal.countdown && Drupal.countdown.loadedLibraries || new Set(),

    /**
     * Library-specific loader functions.
     *
     * @type {Object}
     */
    loaders: Drupal.countdown && Drupal.countdown.loaders || {},

    /**
     * Shared utility functions for all integrations.
     *
     * @namespace
     */
    utils: {
      /**
       * Normalize boolean values from various sources.
       *
       * @param {*} value
       *   The value to normalize.
       *
       * @return {boolean}
       *   The normalized boolean value.
       */
      normalizeBoolean: function (value) {
        // Handle numeric 0/1 values.
        if (value === 0 || value === '0') {
          return false;
        }
        if (value === 1 || value === '1') {
          return true;
        }
        // Handle string booleans.
        if (value === 'false') {
          return false;
        }
        if (value === 'true') {
          return true;
        }
        // Default boolean conversion.
        return !!value;
      },

      /**
       * Normalize numeric values from various sources.
       *
       * @param {*} value
       *   The value to normalize.
       * @param {number} defaultValue
       *   The default value if conversion fails.
       *
       * @return {number}
       *   The normalized numeric value.
       */
      normalizeNumber: function (value, defaultValue) {
        if (typeof value === 'number') {
          return value;
        }
        if (typeof value === 'string') {
          const parsed = parseInt(value, 10);
          return isNaN(parsed) ? defaultValue : parsed;
        }
        return defaultValue;
      },

      /**
       * Auto-detect the countdown type from element.
       *
       * @param {Element} element
       *   The countdown element.
       *
       * @return {string}
       *   The detected type: 'block', 'field', 'view', or 'unknown'.
       */
      detectCountdownType: function (element) {
        if (element.dataset.blockId || element.classList.contains('countdown-block')) {
          return 'block';
        }
        if (element.dataset.fieldId || element.classList.contains('countdown-field')) {
          return 'field';
        }
        if (element.dataset.viewId || element.classList.contains('countdown-view')) {
          return 'view';
        }
        return 'unknown';
      },

      /**
       * Resolve countdown settings from multiple sources with priority.
       *
       * Priority chain (highest to lowest):
       * 1. Data attributes on element
       * 2. Context-specific settings (block/field/views)
       * 3. Global countdown settings
       * 4. Default values
       *
       * @param {Element} element
       *   The countdown element.
       * @param {Object} drupalSettings
       *   The drupalSettings object.
       * @param {string} libraryName
       *   The library identifier (e.g., 'flip', 'tick').
       *
       * @return {Object}
       *   The resolved settings object with all values normalized.
       */
      resolveCountdownSettings: function (element, drupalSettings, libraryName) {
        let settings = {};
        const countdown = drupalSettings.countdown || {};

        // Ensure library name is provided.
        if (!libraryName) {
          libraryName = element.dataset.countdownLibrary || 'countdown';
        }

        // Priority 1: Extract data attributes (highest priority).
        const dataSettings = {};
        const dataPrefix = 'data-' + libraryName.replace('_', '-') + '-';
        for (const attr of element.attributes) {
          if (attr.name.startsWith(dataPrefix)) {
            const key = attr.name.replace(dataPrefix, '').replace(/-/g, '_');
            // Convert string booleans to actual booleans.
            dataSettings[key] = attr.value === 'true' ? true :
              attr.value === 'false' ? false :
                attr.value;
          }
        }

        // Priority 2: Context-specific settings.
        const countdownType = this.detectCountdownType(element);

        switch (countdownType) {
          case 'block':
            const blockId = element.dataset.blockId;
            if (countdown.blocks && countdown.blocks[blockId]) {
              const blockSettings = countdown.blocks[blockId];
              // Merge common and library-specific settings.
              Object.assign(settings, blockSettings.settings || {});
              // Library-specific override if available.
              if (blockSettings.settings && blockSettings.settings[libraryName]) {
                Object.assign(settings, blockSettings.settings[libraryName]);
              }
            }
            break;

          case 'field':
            const fieldId = element.dataset.fieldId;
            if (countdown.fields && countdown.fields[fieldId]) {
              const fieldSettings = countdown.fields[fieldId];
              Object.assign(settings, fieldSettings.settings || {});
              if (fieldSettings.settings && fieldSettings.settings[libraryName]) {
                Object.assign(settings, fieldSettings.settings[libraryName]);
              }
            }
            break;

          case 'view':
            const viewId = element.dataset.viewId;
            if (countdown.views && countdown.views[viewId]) {
              const viewSettings = countdown.views[viewId];
              Object.assign(settings, viewSettings.settings || {});
              if (viewSettings.settings && viewSettings.settings[libraryName]) {
                Object.assign(settings, viewSettings.settings[libraryName]);
              }
            }
            break;
        }

        // Priority 3: Global library settings (fallback).
        if (countdown.global && countdown.global[libraryName]) {
          settings = Object.assign({}, countdown.global[libraryName], settings);
        }

        // Priority 4: Library configuration from drupalSettings.
        if (countdown.libraryConfig) {
          settings = Object.assign({}, countdown.libraryConfig, settings);
        }

        // Priority 5: Library-specific settings from drupalSettings.
        if (countdown.settings) {
          settings = Object.assign({}, countdown.settings, settings);
        }

        // Priority 6: Apply data attributes (override all).
        Object.assign(settings, dataSettings);

        // Extract target date from element if not in settings.
        if (!settings.target_date) {
          settings.target_date = element.dataset.countdownTarget;
        }

        // Extract timezone if available.
        if (!settings.timezone && element.dataset.countdownTimezone) {
          settings.timezone = element.dataset.countdownTimezone;
        }

        // Parse element-level JSON settings if present.
        if (element.dataset.countdownSettings) {
          try {
            const elementSettings = JSON.parse(element.dataset.countdownSettings);
            Object.assign(settings, elementSettings);
          }
          catch (e) {
            console.warn('Countdown: Failed to parse element settings', e);
          }
        }

        return settings;
      },

      /**
       * Handle error conditions with consistent event dispatching.
       *
       * @param {Element} element
       *   The countdown element.
       * @param {string} message
       *   The error message.
       * @param {string} library
       *   The library identifier.
       */
      handleError: function (element, message, library) {
        console.error('Countdown [' + library + ']:', message);
        element.dispatchEvent(new CustomEvent('countdown:error', {
          detail: {
            message: message,
            library: library || 'unknown'
          }
        }));
      },

      /**
       * Dispatch a custom countdown event.
       *
       * @param {Element} element
       *   The countdown element.
       * @param {string} eventType
       *   The event type (e.g., 'initialized', 'tick', 'complete').
       * @param {Object} detail
       *   Additional event details.
       */
      dispatchEvent: function (element, eventType, detail) {
        element.dispatchEvent(new CustomEvent('countdown:' + eventType, {
          detail: detail
        }));
      },

      /**
       * Check if countdown has expired based on target date.
       *
       * @param {string} targetDate
       *   The target date string.
       * @param {string} timezone
       *   The timezone (optional).
       *
       * @return {boolean}
       *   True if expired, false otherwise.
       */
      isExpired: function (targetDate, timezone) {
        if (!targetDate) {
          return true;
        }
        const target = new Date(targetDate + (timezone ? ' ' + timezone : ''));
        return target.getTime() <= Date.now();
      },

      /**
       * Display expired message in element.
       *
       * This function is used by all library integrations to display the
       * finish message when countdown completes. All libraries use the same
       * finish_message field from buildConfigurationForm.
       *
       * @param {Element} element
       *   The countdown element.
       * @param {Object} settings
       *   The settings object containing finish_message.
       * @param {string} library
       *   The library identifier.
       */
      showExpiredMessage: function (element, settings, library) {
        // Get the finish message with fallback.
        const message = settings.finish_message || settings.finishMessage || "Time's up!";

        // Create the expired message display.
        element.innerHTML = '<div class="countdown-display countdown-expired">' + message + '</div>';
        element.classList.add('countdown-expired');

        // Dispatch the complete event for other scripts to listen.
        this.dispatchEvent(element, 'complete', {
          element: element,
          library: library
        });
      }
    },

    /**
     * Store an instance for an element.
     *
     * @param {Element} element
     *   The countdown element.
     * @param {Object} instance
     *   The instance object to store.
     */
    storeInstance: function (element, instance) {
      this.instances.set(element, instance);
    },

    /**
     * Check if a specific library integration is loaded.
     *
     * @param {string} library
     *   The library identifier.
     *
     * @return {boolean}
     *   True if the library integration is loaded.
     */
    isLoaded: function (library) {
      return this.loadedLibraries.has(library);
    },

    /**
     * Load a specific library integration script dynamically.
     *
     * @param {string} library
     *   The library identifier to load.
     * @param {Function} callback
     *   Callback to execute after loading.
     */
    loadIntegration: function (library, callback) {
      // Skip if already loaded.
      if (this.isLoaded(library)) {
        if (callback && typeof callback === 'function') {
          callback();
        }
        return;
      }

      // Get the integration base path from drupalSettings.
      let integrationBasePath = '';
      if (drupalSettings.countdown && drupalSettings.countdown.integrationBasePath) {
        integrationBasePath = drupalSettings.countdown.integrationBasePath;
      } else {
        // Fallback to module path if available.
        const modulePath = (drupalSettings.countdown && drupalSettings.countdown.modulePath)
          ? drupalSettings.countdown.modulePath
          : '/modules/contrib/countdown';
        integrationBasePath = modulePath + '/js/integrations';
      }

      // Build the script filename based on the library.
      let scriptFile = '';
      switch (library) {
        case 'countdown':
          scriptFile = 'countdown.core.integration';
          break;
        case 'flipclock':
          scriptFile = 'countdown.flipclock.integration';
          break;
        case 'flipdown':
          scriptFile = 'countdown.flipdown.integration';
          break;
        case 'flip':
          scriptFile = 'countdown.flip.integration';
          break;
        case 'tick':
          scriptFile = 'countdown.tick.integration';
          break;
        default:
          console.warn('Countdown: Unknown library', library);
          return;
      }

      // Check if we should use minified version.
      const useMinified = document.querySelector('script[src*="countdown.integration.min.js"]') !== null;
      const extension = useMinified ? '.min.js' : '.js';

      // Build full script path.
      const scriptPath = integrationBasePath + '/' + scriptFile + extension;

      // Create and append the script element.
      const script = document.createElement('script');
      script.src = scriptPath;
      script.async = true;
      script.defer = true;
      script.onload = () => {
        this.loadedLibraries.add(library);
        if (callback && typeof callback === 'function') {
          callback();
        }
      };
      script.onerror = () => {
        console.error('Countdown: Failed to load integration for', library, 'from', scriptPath);
      };
      document.head.appendChild(script);
    },

    /**
     * Initialize countdown timers in a context (backward compatible).
     *
     * @param {HTMLElement|Document} context
     *   The context to search for countdown timers.
     * @param {Object} settings
     *   The drupalSettings object.
     */
    initialize: function (context, settings) {
      // Find all uninitialized countdown timers in context.
      const timers = once('countdown-init', '.countdown', context);

      timers.forEach((element) => {
        let library = element.dataset.countdownLibrary;

        if (library) {
          // Mark as initialized early to prevent race conditions.
          element.classList.add('countdown-initialized');

          // Call the element-specific initialization.
          this.initializeCountdown(element, settings);
        }
      });
    },

    /**
     * Initialize a countdown on a specific element.
     *
     * @param {HTMLElement} element
     *   The countdown element.
     * @param {Object} settings
     *   The drupalSettings object.
     */
    initializeCountdown: function (element, settings) {
      // Stop existing instance if present.
      if (this.instances.has(element)) {
        this.stop(element);
      }

      // Determine library with proper precedence.
      let library = element.dataset.countdownLibrary;

      // Check for block context if no library specified.
      if (!library && element.dataset.blockId &&
        settings.countdown &&
        settings.countdown.blocks &&
        settings.countdown.blocks[element.dataset.blockId]) {
        library = settings.countdown.blocks[element.dataset.blockId].library;
      }

      // Check for field context.
      if (!library && element.dataset.fieldId &&
        settings.countdown &&
        settings.countdown.fields &&
        settings.countdown.fields[element.dataset.fieldId]) {
        library = settings.countdown.fields[element.dataset.fieldId].library;
      }

      // Fall back to global settings.
      if (!library && settings.countdown && settings.countdown.activeLibrary) {
        library = settings.countdown.activeLibrary;
      }

      if (!library) {
        console.warn('Countdown: No library specified for element', element);
        element.dispatchEvent(new CustomEvent('countdown:error', {
          detail: { message: 'No countdown library specified' }
        }));
        return;
      }

      // Listen for initialization complete to remove loading placeholder.
      element.addEventListener('countdown:initialized', function (e) {
        const loadingElement = e.target.querySelector('.countdown-display .countdown-loading');
        if (loadingElement) {
          loadingElement.remove();
        }
      }, { once: true });

      // Listen for errors to update placeholder with error message.
      element.addEventListener('countdown:error', function (e) {
        const loadingElement = e.target.querySelector('.countdown-display .countdown-loading');
        if (loadingElement) {
          loadingElement.textContent = e.detail && e.detail.message ?
            e.detail.message : 'Failed to initialize countdown.';
        }
      }, { once: true });

      // Check if integration is loaded, if not load it first.
      if (!this.isLoaded(library)) {
        this.loadIntegration(library, () => {
          // After loading, check if loader was registered.
          if (this.loaders[library]) {
            this.loaders[library](element, settings);
          }
        });
      } else if (this.loaders[library]) {
        // Integration already loaded, call loader directly.
        this.loaders[library](element, settings);
      }
    },

    /**
     * Stop and cleanup a countdown instance.
     *
     * @param {HTMLElement} element
     *   The countdown element.
     */
    stop: function (element) {
      // Get stored instance.
      const instance = this.instances.get(element);

      if (instance) {
        // Call library-specific cleanup if available.
        if (instance.stop && typeof instance.stop === 'function') {
          instance.stop();
        }

        // Handle different instance structures.
        if (instance.instance && instance.instance.stop) {
          instance.instance.stop();
        }

        if (instance.counter && instance.counter.stop) {
          instance.counter.stop();
        }

        // Clear any intervals stored in dataset.
        if (element.dataset.countdownInterval) {
          clearInterval(element.dataset.countdownInterval);
          delete element.dataset.countdownInterval;
        }

        // Remove from instances map.
        this.instances.delete(element);

        // Remove initialized class.
        element.classList.remove('countdown-initialized');

        // Dispatch stopped event.
        element.dispatchEvent(new CustomEvent('countdown:stopped', {
          detail: { element: element }
        }));
      }
    },

    /**
     * Register a library-specific loader function.
     *
     * @param {string} library
     *   The library identifier.
     * @param {Function} loader
     *   The loader function.
     */
    registerLoader: function (library, loader) {
      this.loaders[library] = loader;
    }
  });

  /**
   * Countdown integration behavior.
   *
   * @type {Drupal~behavior}
   */
  Drupal.behaviors.countdownIntegration = {
    attach: function (context, settings) {
      // Use the backward-compatible initialize method.
      Drupal.countdown.initialize(context, settings);
    },

    detach: function (context, settings, trigger) {
      // Clean up countdown instances when elements are removed.
      if (trigger === 'unload') {
        const timers = context.querySelectorAll('.countdown.countdown-initialized');

        timers.forEach(function (element) {
          Drupal.countdown.stop(element);
        });
      }
    }
  };

})(Drupal, drupalSettings, once);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc