services/preview-service.js

/**
 * AngularJS Service for handling CMS builder preview mode.
 *
 * When the game is loaded with ?preview=true inside an iframe, this service
 * replaces the normal API data-loading flow with the following:
 * 1. Game sends CHILD_READY to the parent (tga-admin)
 * 2. Parent responds with GAME_DATA containing the full game object
 * 3. On each builder edit, parent re-sends GAME_DATA with updated data
 *
 * @module previewService
 * @requires $window
 * @requires $rootScope
 * @requires $log
 * @requires URLParamsService
 */
angular.module('tgaApp').service('previewService', [
  '$window',
  '$rootScope',
  '$log',
  '$timeout',
  'URLParamsService',
  function previewService($window, $rootScope, $log, $timeout, URLParamsService) {
    const READY_TIMEOUT_MS = 4000;

    let inIframe;
    try {
      inIframe = $window.self !== $window.top;
    } catch (e) {
      inIframe = true;
    }

    const previewParam = URLParamsService.getParam('preview');
    const active = previewParam === 'true' && inIframe;

    let messageHandler = null;

    /**
     * Whether the app is running in preview mode.
     * True only when ?preview=true AND loaded inside an iframe.
     *
     * @returns {boolean}
     */
    this.isPreview = () => active;

    /**
     * Post CHILD_READY to the parent window so the CMS knows
     * the iframe is ready to receive game data.
     */
    this.sendReady = () => {
      $window.parent.postMessage({ type: 'CHILD_READY' }, '*');
      $log.debug('previewService: CHILD_READY sent');
    };

    /**
     * Listen for GAME_DATA messages from the parent window.
     * The first message triggers the initial load callback.
     * Subsequent messages broadcast 'preview:update' on $rootScope.
     *
     * If no GAME_DATA arrives within READY_TIMEOUT_MS, the fallback
     * callback is invoked so the game can fall back to normal API loading.
     *
     * @param {Function} initialCallback - called once with the first GAME_DATA message
     * @param {Function} [fallbackCallback] - called if no response within timeout
     */
    this.listen = (initialCallback, fallbackCallback) => {
      if (!active) return;

      let initialReceived = false;

      const timeoutId = $timeout(() => {
        if (!initialReceived) {
          $log.warn('previewService: no GAME_DATA received within timeout, falling back to normal load');
          this.destroy();
          if (fallbackCallback) fallbackCallback();
        }
      }, READY_TIMEOUT_MS);

      messageHandler = (event) => {
        const message = event.data;
        if (!message) return;

        if (message.type === 'PREVIEW_ACTION') {
          $rootScope.$applyAsync(() => {
            $rootScope.$broadcast('preview:action', message.action);
          });
          return;
        }

        if (message.type !== 'GAME_DATA') return;

        if (!initialReceived) {
          initialReceived = true;
          $window.clearTimeout(timeoutId);
          $rootScope.$applyAsync(() => {
            initialCallback(message);
          });
        } else {
          $rootScope.$applyAsync(() => {
            $rootScope.$broadcast('preview:update', message);
          });
        }
      };

      $window.addEventListener('message', messageHandler);
    };

    /**
     * Post state back to the parent CMS so it can update its UI.
     * @param {Object} state - arbitrary state object (e.g. { tossupPlaying: true })
     */
    this.sendState = (state) => {
      $window.parent.postMessage({ type: 'PREVIEW_STATE', state }, '*');
    };

    /**
     * Remove the message event listener.
     */
    this.destroy = () => {
      if (messageHandler) {
        $window.removeEventListener('message', messageHandler);
        messageHandler = null;
      }
    };
  },
]);