Коммуникация через IFrame

API коммуникации через Iframe

Этот документ описывает протокол коммуникации на базе postMessage для встраивания Seatmap Editor в виде iframe в родительские приложения.

Обзор

Seatmap Editor поддерживает двунаправленную коммуникацию через браузерный API postMessage, когда встроен в виде iframe. Это позволяет родительским приложениям:

  • Запрашивать у редактора сохранение текущей схемы
  • Получать SVG-экспорт текущей площадки
  • Получать уведомления об изменениях схемы и статусе сохранения
  • Получать уведомления о публикации схемы
  • Отслеживать прогресс сохранения для разных компонентов (метаданные схемы, графика площадки, назначения цен)

Настройка

Встраивание Editor

<iframe
  id="seatmap-editor"
  src="https://editor.seatmap.pro/app?token=YOUR_JWT_TOKEN"
  width="100%"
  height="800px"
>
</iframe>

Обработчик событий сообщений

Настройте в родительском окне обработчик сообщений для получения сообщений от редактора:

window.addEventListener('message', (event) => {
  // Verify origin if needed (recommended for production)
  // if (event.origin !== 'https://editor.seatmap.pro') return;

  const data = event.data;

  switch (data.type) {
    case 'SCHEMA_CHANGED':
      console.log('Schema has changes:', data.hasChanges);
      break;
    case 'SCHEMA_UPDATE_SUCCESS':
      console.log('Schema saved successfully');
      break;
    case 'SCHEMA_UPDATE_ERROR':
      console.error('Schema save failed');
      break;
    case 'SEATMAP_UPDATE_SUCCESS':
      console.log('Venue graphics saved successfully');
      break;
    case 'SEATMAP_UPDATE_ERROR':
      console.error('Venue graphics save failed');
      break;
    case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
      console.log('Price assignments saved successfully');
      break;
    case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
      console.error('Price assignments save failed');
      break;
    case 'PUBLISH_REQUEST':
      console.log('Schema is being published');
      break;
    case 'SVG_RESPONSE':
      handleSvgResponse(data);
      break;
    default:
      console.log('Unknown message type:', data.type);
  }
});

Справочник по типам сообщений

Сводка

Parent → Editor (входящие):

Message Type Описание Legacy Format
SAVE_REQUEST Запрос редактору сохранить текущую схему 'SAVE'
FETCH_SVG Запрос SVG-экспорта текущей схемы -
SET_LANGUAGE Смена языка интерфейса редактора -

Editor → Parent (исходящие):

Message Type Описание Payload
SCHEMA_CHANGED Уведомляет о наличии несохранённых изменений в схеме { hasChanges: boolean }
SCHEMA_UPDATE_SUCCESS Метаданные схемы успешно сохранены -
SCHEMA_UPDATE_ERROR Не удалось сохранить метаданные схемы -
SEATMAP_UPDATE_SUCCESS Графика/формы площадки успешно сохранены -
SEATMAP_UPDATE_ERROR Не удалось сохранить графику/формы площадки { payload: unknown }
UPDATE_PRICE_ASSIGNMENTS_SUCCESS Назначения цен успешно сохранены -
UPDATE_PRICE_ASSIGNMENTS_ERROR Не удалось сохранить назначения цен { payload: unknown }
PRICES_CHANGED Есть несохранённые изменения назначений цен { hasPriceChanges: boolean }
PRICE_CREATED В списке цен события создана цена { price: { id, name } }
PRICE_UPDATED Цена в списке цен события переименована { price: { id, name } }
PRICE_DELETED Цена удалена из списка цен события { priceId: number }
PUBLISH_REQUEST Пользователь нажал кнопку публикации { payload: ISchemaSettings }
SVG_RESPONSE Ответ на запрос FETCH_SVG (успех или ошибка) { success, svg?, error? }

Parent → Editor (входящие сообщения)

Сообщения, отправляемые из родительского окна в iframe редактора.

SAVE_REQUEST

Запускает сохранение текущей схемы редактором. Эта операция сохраняет все изменения, внесённые в макет площадки, включая места, секции, ряды, ценовые зоны и фоновую графику.

Что сохраняется:

  • Макет и структура площадки
  • Позиции, подписи и свойства мест
  • Определения и границы секций
  • Конфигурации и нумерация рядов
  • Назначения ценовых зон
  • Фоновые SVG/изображения и их позиционирование
  • Пользовательские формы и подписи
  • Настройки клиентского viewbox

Формат:

iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

Legacy-формат (устаревший):

iframe.contentWindow.postMessage('SAVE', '*');

Ответные сообщения:

Операция сохранения может вызвать несколько ответных сообщений в зависимости от того, что было сохранено:

  • SCHEMA_UPDATE_SUCCESS - Метаданные и настройки схемы успешно сохранены
  • SCHEMA_UPDATE_ERROR - Не удалось сохранить схему
  • SEATMAP_UPDATE_SUCCESS - Графика/формы площадки успешно сохранены
  • SEATMAP_UPDATE_ERROR - Не удалось сохранить графику/формы площадки
  • UPDATE_PRICE_ASSIGNMENTS_SUCCESS - Назначения цен успешно сохранены
  • UPDATE_PRICE_ASSIGNMENTS_ERROR - Не удалось сохранить назначения цен

Базовый пример сохранения:

const iframe = document.getElementById('seatmap-editor');

// Send save request
iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

// Listen for response
window.addEventListener('message', (event) => {
  if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
    console.log('Schema saved successfully!');
    // Proceed with next action
  } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
    console.error('Failed to save schema');
  }
});

Полный пример обработчика сохранения:

let saveInProgress = false;
let saveResults = {
  schema: null,
  seatmap: null,
  prices: null,
};

function handleSave() {
  if (saveInProgress) {
    console.warn('Save already in progress');
    return;
  }

  saveInProgress = true;
  saveResults = { schema: null, seatmap: null, prices: null };

  const iframe = document.getElementById('seatmap-editor');
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  // Set timeout to detect save completion
  setTimeout(checkSaveCompletion, 5000);
}

function checkSaveCompletion() {
  if (!saveInProgress) return;

  const allCompleted =
    saveResults.schema !== null && saveResults.seatmap !== null && saveResults.prices !== null;

  if (allCompleted) {
    const allSuccess = saveResults.schema && saveResults.seatmap && saveResults.prices;

    if (allSuccess) {
      console.log('✓ All changes saved successfully');
      onSaveComplete(true);
    } else {
      console.error('✗ Some save operations failed');
      onSaveComplete(false);
    }

    saveInProgress = false;
  }
}

window.addEventListener('message', (event) => {
  if (!saveInProgress) return;

  switch (event.data.type) {
    case 'SCHEMA_UPDATE_SUCCESS':
      saveResults.schema = true;
      checkSaveCompletion();
      break;

    case 'SCHEMA_UPDATE_ERROR':
      saveResults.schema = false;
      checkSaveCompletion();
      break;

    case 'SEATMAP_UPDATE_SUCCESS':
      saveResults.seatmap = true;
      checkSaveCompletion();
      break;

    case 'SEATMAP_UPDATE_ERROR':
      saveResults.seatmap = false;
      checkSaveCompletion();
      break;

    case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
      saveResults.prices = true;
      checkSaveCompletion();
      break;

    case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
      saveResults.prices = false;
      checkSaveCompletion();
      break;
  }
});

function onSaveComplete(success) {
  if (success) {
    showNotification('Changes saved successfully', 'success');
    // Enable other actions that require saved state
    document.getElementById('export-btn').disabled = false;
  } else {
    showNotification('Failed to save some changes', 'error');
  }
}

Сохранение с индикатором загрузки:

function saveWithFeedback() {
  const iframe = document.getElementById('seatmap-editor');
  const saveBtn = document.getElementById('save-btn');
  const spinner = document.getElementById('spinner');

  // Show loading state
  saveBtn.disabled = true;
  spinner.style.display = 'inline-block';

  // Send save request
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  // Set timeout for UI feedback
  const timeout = setTimeout(() => {
    saveBtn.disabled = false;
    spinner.style.display = 'none';
    showNotification('Save operation timed out', 'warning');
  }, 30000); // 30 second timeout

  // Track save responses
  const listener = (event) => {
    if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
      clearTimeout(timeout);
      saveBtn.disabled = false;
      spinner.style.display = 'none';
      showNotification('Saved successfully!', 'success');
      window.removeEventListener('message', listener);
    } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
      clearTimeout(timeout);
      saveBtn.disabled = false;
      spinner.style.display = 'none';
      showNotification('Save failed', 'error');
      window.removeEventListener('message', listener);
    }
  };

  window.addEventListener('message', listener);
}

Реализация автосохранения:

let autoSaveTimeout = null;
let hasUnsavedChanges = false;

window.addEventListener('message', (event) => {
  // Track unsaved changes
  if (event.data.type === 'SCHEMA_CHANGED') {
    hasUnsavedChanges = event.data.hasChanges;

    if (hasUnsavedChanges) {
      // Debounce auto-save
      clearTimeout(autoSaveTimeout);
      autoSaveTimeout = setTimeout(() => {
        autoSave();
      }, 5000); // Auto-save after 5 seconds of inactivity
    }
  }
});

function autoSave() {
  if (!hasUnsavedChanges) return;

  console.log('Auto-saving...');
  const iframe = document.getElementById('seatmap-editor');
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
}

// Save before page unload
window.addEventListener('beforeunload', (event) => {
  if (hasUnsavedChanges) {
    event.preventDefault();
    event.returnValue = 'You have unsaved changes. Do you want to leave?';
  }
});

FETCH_SVG

Запрашивает SVG-экспорт текущей схемы. Схема должна быть сохранена перед экспортом.

Формат:

iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');

Ответное сообщение:

  • SVG_RESPONSE - Содержит статус успеха, данные SVG или сообщение об ошибке

Пример:

const iframe = document.getElementById('seatmap-editor');

// Request SVG export
iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');

// Listen for response
window.addEventListener('message', (event) => {
  if (event.data.type === 'SVG_RESPONSE') {
    if (event.data.success) {
      const svgContent = event.data.svg;
      // Use SVG data (download, display, etc.)
      downloadSvg(svgContent);
    } else {
      console.error('SVG export failed:', event.data.error);
      // Possible errors:
      // - "Schema must be saved before export"
      // - Export API errors
    }
  }
});

function downloadSvg(svgContent) {
  const blob = new Blob([svgContent], { type: 'image/svg+xml' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = 'venue.svg';
  link.click();
  URL.revokeObjectURL(url);
}

SET_LANGUAGE

Меняет язык интерфейса редактора во время работы. Позволяет синхронизировать язык встроенного редактора с языком вашего приложения без перезагрузки iframe.

Формат:

iframe.contentWindow.postMessage({ type: 'SET_LANGUAGE', language: 'pl' }, '*');

Поддерживаемые коды языков: en, ru, de, es, fr, it, zh, pl. Неподдерживаемые или некорректные коды игнорируются, текущий язык сохраняется.

Чтобы задать язык при первой загрузке редактора (до действий пользователя), добавьте параметр запроса language к URL iframe:

<iframe src="https://editor.seatmap.pro/app?language=pl" width="100%" height="800px"></iframe>

Пример:

const iframe = document.getElementById('seatmap-editor');

// Переключить редактор на польский
iframe.contentWindow.postMessage({ type: 'SET_LANGUAGE', language: 'pl' }, '*');

Editor → Parent (исходящие сообщения)

Сообщения, отправляемые из iframe редактора в родительское окно.

SCHEMA_CHANGED

Уведомляет родителя о наличии в схеме несохранённых изменений.

Payload:

{
  type: 'SCHEMA_CHANGED',
  hasChanges: boolean
}

Пример:

window.addEventListener('message', (event) => {
  if (event.data.type === 'SCHEMA_CHANGED') {
    if (event.data.hasChanges) {
      // Show unsaved changes indicator
      document.getElementById('save-indicator').style.display = 'block';
    } else {
      // Hide unsaved changes indicator
      document.getElementById('save-indicator').style.display = 'none';
    }
  }
});

SCHEMA_UPDATE_SUCCESS

Отправляется после успешного завершения операции сохранения схемы.

Payload:

{
  type: 'SCHEMA_UPDATE_SUCCESS';
}

SCHEMA_UPDATE_ERROR

Отправляется при сбое операции сохранения схемы.

Payload:

{
  type: 'SCHEMA_UPDATE_ERROR';
}

SEATMAP_UPDATE_SUCCESS

Отправляется после успешного сохранения графики/форм площадки.

Payload:

{
  type: 'SEATMAP_UPDATE_SUCCESS';
}

SEATMAP_UPDATE_ERROR

Отправляется при сбое сохранения графики/форм площадки.

Payload:

{
  type: 'SEATMAP_UPDATE_ERROR';
}

SVG_RESPONSE

Ответ на запрос FETCH_SVG, содержащий экспортированный SVG или информацию об ошибке.

Payload при успехе:

{
  type: 'SVG_RESPONSE',
  success: true,
  svg: string  // SVG content as string
}

Payload при ошибке:

{
  type: 'SVG_RESPONSE',
  success: false,
  error: string  // Error message
}

Типичные сообщения об ошибках:

  • "Schema must be saved before export" - Схема ещё не была сохранена
  • Локализованные сообщения об ошибках экспорта из API

UPDATE_PRICE_ASSIGNMENTS_SUCCESS

Отправляется после успешного завершения обновления назначений цен.

Payload:

{
  type: 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS';
}

UPDATE_PRICE_ASSIGNMENTS_ERROR

Отправляется при сбое обновления назначений цен.

Payload:

{
  type: 'UPDATE_PRICE_ASSIGNMENTS_ERROR',
  payload: unknown  // Error details
}

PRICES_CHANGED

Уведомляет родительское окно о наличии несохранённых изменений в назначениях цен — аналог SCHEMA_CHANGED для ценообразования. Отправляется с hasPriceChanges: true, когда пользователь назначает или снимает цены с мест или секций, и с hasPriceChanges: false после сохранения назначений.

Payload:

{
  type: 'PRICES_CHANGED',
  hasPriceChanges: boolean
}

PRICE_CREATED

Отправляется сразу после создания новой цены в списке цен события.

В отличие от назначений цен, сам список цен принадлежит событию и сохраняется немедленно: создание, переименование или удаление цены сразу вызывает бэкенд и не ждёт действия Save для схемы. Сообщения PRICE_CREATED, PRICE_UPDATED и PRICE_DELETED позволяют родительскому приложению синхронизировать собственный каталог цен с этими немедленными изменениями.

Payload:

{
  type: 'PRICE_CREATED',
  price: {
    id: number,
    name: string
  }
}

Пример:

window.addEventListener('message', (event) => {
  switch (event.data.type) {
    case 'PRICE_CREATED':
      console.log('Price created:', event.data.price.id, event.data.price.name);
      break;
    case 'PRICE_UPDATED':
      console.log('Price renamed:', event.data.price.id, event.data.price.name);
      break;
    case 'PRICE_DELETED':
      console.log('Price deleted:', event.data.priceId);
      break;
  }
});

PRICE_UPDATED

Отправляется сразу после переименования существующей цены в списке цен события.

Payload:

{
  type: 'PRICE_UPDATED',
  price: {
    id: number,
    name: string
  }
}

PRICE_DELETED

Отправляется сразу после удаления цены из списка цен события.

Payload:

{
  type: 'PRICE_DELETED',
  priceId: number
}

PUBLISH_REQUEST

Отправляется, когда пользователь нажимает кнопку «Publish» в редакторе. Это означает, что схема публикуется из режима черновика в рабочий режим.

Payload:

{
  type: 'PUBLISH_REQUEST',
  payload: ISchemaSettings  // Schema settings with draft: false
}

Пример:

window.addEventListener('message', (event) => {
  if (event.data.type === 'PUBLISH_REQUEST') {
    console.log('Schema is being published');
    console.log('Schema settings:', event.data.payload);

    // Parent application might want to:
    // - Show publish confirmation notification
    // - Update UI to reflect published status
    // - Trigger additional workflows (notifications, webhooks, etc.)
  }
});

Важные замечания:

  • Это сообщение отправляется, когда пользователь инициирует публикацию, но до завершения серверной операции
  • Схема всё равно будет сохранена через обычный поток сохранения, вызывая SCHEMA_UPDATE_SUCCESS или SCHEMA_UPDATE_ERROR
  • Родитель должен дождаться SCHEMA_UPDATE_SUCCESS, чтобы подтвердить успешное завершение операции публикации

Пример процесса публикации:

let publishInProgress = false;

window.addEventListener('message', (event) => {
  switch (event.data.type) {
    case 'PUBLISH_REQUEST':
      publishInProgress = true;
      console.log('Publishing schema...');
      showNotification('Publishing schema...', 'info');
      break;

    case 'SCHEMA_UPDATE_SUCCESS':
      if (publishInProgress) {
        publishInProgress = false;
        console.log('Schema published successfully!');
        showNotification('Schema published successfully!', 'success');
        // Trigger post-publish workflows
        onSchemaPublished(event.data);
      }
      break;

    case 'SCHEMA_UPDATE_ERROR':
      if (publishInProgress) {
        publishInProgress = false;
        console.error('Failed to publish schema');
        showNotification('Failed to publish schema', 'error');
      }
      break;
  }
});

Полный пример интеграции

<!DOCTYPE html>
<html>
  <head>
    <title>Seatmap Editor Integration</title>
    <style>
      #container {
        display: flex;
        flex-direction: column;
        height: 100vh;
      }
      #controls {
        padding: 10px;
        background: #f5f5f5;
        border-bottom: 1px solid #ddd;
      }
      button {
        margin-right: 10px;
        padding: 8px 16px;
      }
      .indicator {
        display: none;
        color: orange;
        margin-left: 10px;
      }
      .indicator.visible {
        display: inline;
      }
      #editor {
        flex: 1;
        border: none;
      }
      #log {
        height: 150px;
        overflow-y: auto;
        padding: 10px;
        background: #fafafa;
        border-top: 1px solid #ddd;
        font-family: monospace;
        font-size: 12px;
      }
    </style>
  </head>
  <body>
    <div id="container">
      <div id="controls">
        <button id="save-btn">Save Schema</button>
        <button id="export-svg-btn">Export SVG</button>
        <span id="unsaved-indicator" class="indicator">● Unsaved changes</span>
      </div>

      <iframe
        id="editor"
        src="https://editor.seatmap.pro/app?token=YOUR_TOKEN&hidden=save"
      ></iframe>

      <div id="log"></div>
    </div>

    <script>
      const iframe = document.getElementById('editor');
      const logEl = document.getElementById('log');
      const indicator = document.getElementById('unsaved-indicator');

      function log(message) {
        const time = new Date().toLocaleTimeString();
        logEl.innerHTML += `[${time}] ${message}<br>`;
        logEl.scrollTop = logEl.scrollHeight;
      }

      // Listen for messages from iframe
      window.addEventListener('message', (event) => {
        // In production, verify origin:
        // if (event.origin !== 'https://editor.seatmap.pro') return;

        const data = event.data;
        log(`Received: ${JSON.stringify(data)}`);

        switch (data.type) {
          case 'SCHEMA_CHANGED':
            if (data.hasChanges) {
              indicator.classList.add('visible');
            } else {
              indicator.classList.remove('visible');
            }
            break;

          case 'SCHEMA_UPDATE_SUCCESS':
            log('✓ Schema saved successfully');
            break;

          case 'SCHEMA_UPDATE_ERROR':
            log('✗ Schema save failed');
            break;

          case 'SEATMAP_UPDATE_SUCCESS':
            log('✓ Venue graphics saved successfully');
            break;

          case 'SEATMAP_UPDATE_ERROR':
            log('✗ Venue graphics save failed');
            break;

          case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
            log('✓ Price assignments saved successfully');
            break;

          case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
            log('✗ Price assignments save failed');
            break;

          case 'PUBLISH_REQUEST':
            log('📤 Schema is being published...');
            break;

          case 'SVG_RESPONSE':
            if (data.success) {
              log('✓ SVG export successful');
              downloadSvg(data.svg);
            } else {
              log(`✗ SVG export failed: ${data.error}`);
              alert(data.error);
            }
            break;
        }
      });

      // Save button
      document.getElementById('save-btn').addEventListener('click', () => {
        log('Sending SAVE_REQUEST...');
        iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
      });

      // Export SVG button
      document.getElementById('export-svg-btn').addEventListener('click', () => {
        log('Sending FETCH_SVG...');
        iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
      });

      function downloadSvg(svgContent) {
        const blob = new Blob([svgContent], { type: 'image/svg+xml' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = `venue_${Date.now()}.svg`;
        link.click();
        URL.revokeObjectURL(url);
        log('SVG downloaded');
      }
    </script>
  </body>
</html>

Обработка ошибок

Типичные сценарии

Схема не сохранена

При запросе SVG-экспорта без предварительного сохранения:

// Response:
{
  type: 'SVG_RESPONSE',
  success: false,
  error: 'Schema must be saved before export'
}

Решение: Сначала запустите сохранение, затем запросите SVG:

function saveAndExport() {
  const iframe = document.getElementById('seatmap-editor');

  // Send save request
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  // Wait for save confirmation
  const listener = (event) => {
    if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
      // Now request SVG
      iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
      window.removeEventListener('message', listener);
    } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
      console.error('Save failed, cannot export SVG');
      alert('Please save the schema before exporting');
      window.removeEventListener('message', listener);
    }
  };
  window.addEventListener('message', listener);
}

Полный процесс «сохранить, затем экспортировать»:

function saveAndExportWithFeedback() {
  const iframe = document.getElementById('seatmap-editor');
  const exportBtn = document.getElementById('export-btn');

  exportBtn.disabled = true;
  exportBtn.textContent = 'Saving...';

  // Step 1: Save the schema
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  let saveCompleted = false;

  const messageHandler = (event) => {
    // Handle save response
    if (!saveCompleted) {
      if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
        saveCompleted = true;
        exportBtn.textContent = 'Exporting...';

        // Step 2: Request SVG export
        iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
      } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
        exportBtn.disabled = false;
        exportBtn.textContent = 'Export SVG';
        alert('Failed to save schema. Cannot export.');
        window.removeEventListener('message', messageHandler);
      }
    }
    // Handle export response
    else {
      if (event.data.type === 'SVG_RESPONSE') {
        exportBtn.disabled = false;
        exportBtn.textContent = 'Export SVG';

        if (event.data.success) {
          console.log('Export successful');
          downloadSvg(event.data.svg);
        } else {
          alert('Export failed: ' + event.data.error);
        }

        window.removeEventListener('message', messageHandler);
      }
    }
  };

  window.addEventListener('message', messageHandler);

  // Timeout handler
  setTimeout(() => {
    exportBtn.disabled = false;
    exportBtn.textContent = 'Export SVG';
    window.removeEventListener('message', messageHandler);
  }, 60000); // 60 second timeout
}

function downloadSvg(svgContent) {
  const blob = new Blob([svgContent], { type: 'image/svg+xml' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `venue_${Date.now()}.svg`;
  link.click();
  URL.revokeObjectURL(url);
}

Ошибки API

Сетевые или серверные ошибки во время экспорта:

{
  type: 'SVG_RESPONSE',
  success: false,
  error: 'Export failed'  // Localized error message
}

Решение: Покажите понятное пользователю сообщение об ошибке и предложите возможность повтора.

Соображения по размеру сообщений

SVG-экспорты могут быть большими, особенно для сложных площадок:

  • Небольшие площадки: 10KB - 100KB
  • Средние площадки: 100KB - 1MB
  • Крупные площадки: 1MB - 10MB+

API postMessage поддерживает сообщения размером до ~64MB в современных браузерах, но очень большие полезные нагрузки могут вызвать проблемы с производительностью. Для площадок с чрезвычайно сложной графикой рассмотрите следующие варианты:

  • Использование прямого эндпоинта API (/api/export/${schemaId}/) вместо этого
  • Скачивание через серверную обработку
  • Внедрение индикаторов прогресса для пользователя

Тестирование

Тестовые файлы

В репозитории есть тестовые HTML-файлы для разработки и отладки:

  1. public/sso_test.html - Полнофункциональная тестовая страница с:

    • Процессом SSO-аутентификации
    • Встраиванием iframe в модальном окне
    • Кнопкой запроса сохранения
    • Панелью логирования сообщений
    • Отображением сообщений в реальном времени
  2. public/iframe_modal_test.html - Простой тест модального окна с:

    • Базовым встраиванием iframe
    • Логированием событий сообщений
    • Отслеживанием изменений схемы

Локальное тестирование

# Start development server
yarn start

# Open test page
open http://localhost:3000/sso_test.html

Соображения безопасности

Проверка origin

Всегда проверяйте origin получаемых сообщений в продакшене:

window.addEventListener('message', (event) => {
  // Verify the origin
  const allowedOrigins = ['https://editor.seatmap.pro', 'https://editor.seatmap.dev'];

  if (!allowedOrigins.includes(event.origin)) {
    console.warn('Message from untrusted origin:', event.origin);
    return;
  }

  // Process message
  handleMessage(event.data);
});

Целевой origin

При отправке сообщений в iframe указывайте целевой origin:

// Instead of '*', use specific origin
iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, 'https://editor.seatmap.pro');

Аутентификация

Редактор требует JWT-аутентификации. Передавайте токены через параметры URL:

https://editor.seatmap.pro/app?token=JWT_TOKEN&refreshToken=REFRESH_TOKEN

Убедитесь, что токены передаются безопасно и имеют подходящее время истечения.

Устранение неполадок

Сообщения не доходят

  1. Проверьте, что iframe загружен:

    iframe.addEventListener('load', () => {
      console.log('Iframe loaded, ready for messages');
    });
    
  2. Проверьте доступ к contentWindow:

    if (!iframe.contentWindow) {
      console.error('Cannot access iframe contentWindow');
    }
    
  3. Проверьте ограничения cross-origin:

    • Убедитесь, что заданы корректные заголовки CORS
    • Используйте консоль браузера для проверки ошибок безопасности

Сохранение не работает

Типичные причины:

  1. Проблемы аутентификации

    • Токен истёк или недействителен
    • У пользователя нет прав на запись
    • Сессия истекла
  2. Ошибки валидации

    • Данные схемы неполные или недействительные
    • Отсутствуют обязательные поля
    • Ошибки формата данных
  3. Сетевые проблемы

    • Эндпоинт API недоступен
    • Ограничения CORS
    • Файрвол блокирует запросы
  4. Проблемы состояния

    • Нет изменений для сохранения
    • Редактор не полностью загружен
    • Параллельные операции сохранения

Шаги отладки:

// Check if editor is loaded
iframe.addEventListener('load', () => {
  console.log('Editor iframe loaded');
});

// Monitor all messages
window.addEventListener('message', (event) => {
  console.log('Message received:', event.data);
});

// Check for errors in browser console
// Check Network tab for failed API calls
// Verify authentication token is present in URL

Чек-лист операции сохранения:

  • iframe редактора полностью загружен
  • Пользователь аутентифицирован (токен присутствует)
  • У пользователя есть права на редактирование площадки
  • В схеме есть несохранённые изменения (отслеживайте SCHEMA_CHANGED)
  • Никакая другая операция сохранения не выполняется
  • Сетевое соединение стабильно

Сбой экспорта SVG

Типичные причины:

  • Схема не сохранена (самая частая)
  • Проблемы с сетевым соединением
  • Серверные ошибки экспорта
  • Недействительные данные схемы

Диаграмма потока сообщений

Поток операции сохранения

Parent sends SAVE_REQUEST
    ↓
Editor begins save operation
    ↓
Editor sends SCHEMA_CHANGED (hasChanges: false)
    ↓
Editor saves schema metadata → SCHEMA_UPDATE_SUCCESS or SCHEMA_UPDATE_ERROR
    ↓
Editor saves venue graphics → SEATMAP_UPDATE_SUCCESS or SEATMAP_UPDATE_ERROR
    ↓
Editor saves price assignments → UPDATE_PRICE_ASSIGNMENTS_SUCCESS or UPDATE_PRICE_ASSIGNMENTS_ERROR
    ↓
Save operation complete

Поток операции публикации

User clicks "Publish" button in editor
    ↓
Editor sends PUBLISH_REQUEST (with payload: { draft: false, ... })
    ↓
Editor triggers save operation (same flow as above)
    ↓
SCHEMA_UPDATE_SUCCESS indicates publish completed successfully

Поток экспорта SVG

Parent sends FETCH_SVG
    ↓
Editor checks if schema is saved
    ↓
If not saved: SVG_RESPONSE (success: false, error: "Schema must be saved before export")
    ↓
If saved: Editor fetches SVG from API
    ↓
SVG_RESPONSE (success: true/false, svg or error)

Типичные сценарии работы

Комплексный обработчик сообщений

class SeatmapEditorIntegration {
  constructor(iframeId) {
    this.iframe = document.getElementById(iframeId);
    this.hasUnsavedChanges = false;
    this.saveInProgress = false;
    this.publishInProgress = false;
    this.saveResults = { schema: null, seatmap: null, prices: null };

    this.initMessageListener();
  }

  initMessageListener() {
    window.addEventListener('message', (event) => {
      // Verify origin in production
      // if (event.origin !== 'https://editor.seatmap.pro') return;

      this.handleMessage(event.data);
    });
  }

  handleMessage(data) {
    console.log('Received message:', data);

    switch (data.type) {
      case 'SCHEMA_CHANGED':
        this.hasUnsavedChanges = data.hasChanges;
        this.updateUI();
        break;

      case 'SCHEMA_UPDATE_SUCCESS':
        this.saveResults.schema = true;
        this.checkSaveCompletion();
        break;

      case 'SCHEMA_UPDATE_ERROR':
        this.saveResults.schema = false;
        this.checkSaveCompletion();
        break;

      case 'SEATMAP_UPDATE_SUCCESS':
        this.saveResults.seatmap = true;
        this.checkSaveCompletion();
        break;

      case 'SEATMAP_UPDATE_ERROR':
        this.saveResults.seatmap = false;
        this.checkSaveCompletion();
        break;

      case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
        this.saveResults.prices = true;
        this.checkSaveCompletion();
        break;

      case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
        this.saveResults.prices = false;
        this.checkSaveCompletion();
        break;

      case 'PUBLISH_REQUEST':
        console.log('Schema is being published');
        this.publishInProgress = true;
        this.showNotification('Publishing schema...', 'info');
        break;

      case 'SVG_RESPONSE':
        this.handleSvgResponse(data);
        break;

      default:
        console.log('Unknown message type:', data.type);
    }
  }

  checkSaveCompletion() {
    if (!this.saveInProgress) return;

    const { schema, seatmap, prices } = this.saveResults;
    if (schema === null || seatmap === null || prices === null) return;

    this.saveInProgress = false;
    const success = schema && seatmap && prices;

    if (success) {
      this.hasUnsavedChanges = false;
      this.showNotification(
        this.publishInProgress ? 'Published successfully!' : 'Saved successfully!',
        'success',
      );
      this.onSaveComplete(true);
    } else {
      this.showNotification('Save failed', 'error');
      this.onSaveComplete(false);
    }

    this.publishInProgress = false;
  }

  save() {
    if (this.saveInProgress) {
      console.warn('Save already in progress');
      return;
    }

    this.saveInProgress = true;
    this.saveResults = { schema: null, seatmap: null, prices: null };
    this.iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
  }

  exportSvg() {
    this.iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
  }

  handleSvgResponse(data) {
    if (data.success) {
      this.downloadFile(data.svg, 'venue.svg', 'image/svg+xml');
      this.showNotification('SVG exported successfully', 'success');
    } else {
      this.showNotification(`Export failed: ${data.error}`, 'error');
    }
  }

  downloadFile(content, filename, mimeType) {
    const blob = new Blob([content], { type: mimeType });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.click();
    URL.revokeObjectURL(url);
  }

  updateUI() {
    const indicator = document.getElementById('unsaved-indicator');
    if (indicator) {
      indicator.style.display = this.hasUnsavedChanges ? 'inline' : 'none';
    }
  }

  showNotification(message, type) {
    console.log(`[${type}] ${message}`);
    // Implement your notification system here
  }

  onSaveComplete(success) {
    // Override this method to handle save completion
    console.log('Save completed:', success);
  }
}

// Usage
const editor = new SeatmapEditorIntegration('seatmap-editor');

document.getElementById('save-btn').addEventListener('click', () => {
  editor.save();
});

document.getElementById('export-btn').addEventListener('click', () => {
  editor.exportSvg();
});

// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', (event) => {
  if (editor.hasUnsavedChanges) {
    event.preventDefault();
    event.returnValue = 'You have unsaved changes. Do you want to leave?';
  }
});

Сохранение и закрытие модального окна

function saveAndClose() {
  const iframe = document.getElementById('seatmap-editor');
  const modal = document.getElementById('editor-modal');

  // Show saving indicator
  showStatus('Saving changes...');

  // Send save request
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  const listener = (event) => {
    if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
      showStatus('Saved successfully!');
      setTimeout(() => {
        modal.style.display = 'none';
      }, 1000);
      window.removeEventListener('message', listener);
    } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
      showStatus('Save failed!', 'error');
      // Keep modal open on error
      window.removeEventListener('message', listener);
    }
  };

  window.addEventListener('message', listener);
}

// Warn before closing with unsaved changes
let hasUnsavedChanges = false;

window.addEventListener('message', (event) => {
  if (event.data.type === 'SCHEMA_CHANGED') {
    hasUnsavedChanges = event.data.hasChanges;
  }
});

function attemptClose() {
  if (hasUnsavedChanges) {
    if (confirm('You have unsaved changes. Save before closing?')) {
      saveAndClose();
    } else {
      closeModal();
    }
  } else {
    closeModal();
  }
}

Периодическое автосохранение с отображением статуса

class EditorManager {
  constructor(iframeId) {
    this.iframe = document.getElementById(iframeId);
    this.hasChanges = false;
    this.lastSaveTime = null;
    this.autoSaveInterval = 60000; // 1 minute
    this.autoSaveTimer = null;

    this.initMessageListener();
    this.startAutoSave();
  }

  initMessageListener() {
    window.addEventListener('message', (event) => {
      this.handleMessage(event.data);
    });
  }

  handleMessage(data) {
    switch (data.type) {
      case 'SCHEMA_CHANGED':
        this.hasChanges = data.hasChanges;
        this.updateStatus();
        if (data.hasChanges) {
          this.scheduleAutoSave();
        }
        break;

      case 'SCHEMA_UPDATE_SUCCESS':
        this.hasChanges = false;
        this.lastSaveTime = new Date();
        this.updateStatus();
        break;

      case 'SCHEMA_UPDATE_ERROR':
        this.showError('Failed to save changes');
        break;
    }
  }

  scheduleAutoSave() {
    clearTimeout(this.autoSaveTimer);
    this.autoSaveTimer = setTimeout(() => {
      if (this.hasChanges) {
        this.save();
      }
    }, this.autoSaveInterval);
  }

  startAutoSave() {
    setInterval(() => {
      if (this.hasChanges) {
        console.log('Auto-saving...');
        this.save();
      }
    }, this.autoSaveInterval);
  }

  save() {
    this.iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
  }

  updateStatus() {
    const statusEl = document.getElementById('save-status');
    if (this.hasChanges) {
      statusEl.textContent = '● Unsaved changes';
      statusEl.className = 'status-warning';
    } else if (this.lastSaveTime) {
      const elapsed = Math.floor((new Date() - this.lastSaveTime) / 1000);
      statusEl.textContent = `✓ Saved ${elapsed}s ago`;
      statusEl.className = 'status-success';
    } else {
      statusEl.textContent = 'No changes';
      statusEl.className = 'status-info';
    }
  }

  showError(message) {
    const statusEl = document.getElementById('save-status');
    statusEl.textContent = `✗ ${message}`;
    statusEl.className = 'status-error';
  }
}

// Usage
const editor = new EditorManager('seatmap-editor');

// Manual save button
document.getElementById('save-btn').addEventListener('click', () => {
  editor.save();
});

Процесс «сохранить — экспортировать — скачать — закрыть»

async function completeWorkflow() {
  const iframe = document.getElementById('seatmap-editor');
  const statusEl = document.getElementById('workflow-status');

  try {
    // Step 1: Save
    statusEl.textContent = 'Step 1/3: Saving schema...';
    await sendMessageAndWait(
      iframe,
      { type: 'SAVE_REQUEST' },
      'SCHEMA_UPDATE_SUCCESS',
      'SCHEMA_UPDATE_ERROR',
    );

    // Step 2: Export SVG
    statusEl.textContent = 'Step 2/3: Exporting SVG...';
    const svgData = await sendMessageAndWait(iframe, { type: 'FETCH_SVG' }, null, null, true);

    if (!svgData.success) {
      throw new Error(svgData.error);
    }

    // Step 3: Download
    statusEl.textContent = 'Step 3/3: Downloading...';
    downloadFile(svgData.svg, 'venue.svg', 'image/svg+xml');

    // Step 4: Close
    statusEl.textContent = 'Complete! Closing...';
    setTimeout(() => {
      closeEditor();
    }, 1000);
  } catch (error) {
    statusEl.textContent = `Error: ${error.message}`;
    console.error('Workflow failed:', error);
  }
}

function sendMessageAndWait(iframe, message, successType, errorType, returnData = false) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      window.removeEventListener('message', handler);
      reject(new Error('Operation timed out'));
    }, 30000);

    const handler = (event) => {
      if (returnData && event.data.type === 'SVG_RESPONSE') {
        clearTimeout(timeout);
        window.removeEventListener('message', handler);
        resolve(event.data);
      } else if (successType && event.data.type === successType) {
        clearTimeout(timeout);
        window.removeEventListener('message', handler);
        resolve(event.data);
      } else if (errorType && event.data.type === errorType) {
        clearTimeout(timeout);
        window.removeEventListener('message', handler);
        reject(new Error('Operation failed'));
      }
    };

    window.addEventListener('message', handler);
    iframe.contentWindow.postMessage(message, '*');
  });
}

function downloadFile(content, filename, mimeType) {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  link.click();
  URL.revokeObjectURL(url);
}

Пакетные операции с отслеживанием прогресса

class BatchOperationManager {
  constructor(iframeId) {
    this.iframe = document.getElementById(iframeId);
    this.operations = [];
    this.currentOperation = null;
  }

  addOperation(name, message, successTypes, errorTypes) {
    this.operations.push({
      name,
      message,
      successTypes: Array.isArray(successTypes) ? successTypes : [successTypes],
      errorTypes: Array.isArray(errorTypes) ? errorTypes : [errorTypes],
      status: 'pending',
    });
  }

  async executeAll() {
    const totalOps = this.operations.length;

    for (let i = 0; i < totalOps; i++) {
      const op = this.operations[i];
      this.updateProgress(i + 1, totalOps, op.name);

      try {
        await this.executeOperation(op);
        op.status = 'success';
      } catch (error) {
        op.status = 'failed';
        console.error(`Operation "${op.name}" failed:`, error);
        throw error; // Stop on first failure
      }
    }

    this.updateProgress(totalOps, totalOps, 'Complete');
  }

  executeOperation(op) {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        window.removeEventListener('message', handler);
        reject(new Error(`${op.name} timed out`));
      }, 60000);

      const handler = (event) => {
        if (op.successTypes.includes(event.data.type)) {
          clearTimeout(timeout);
          window.removeEventListener('message', handler);
          resolve(event.data);
        } else if (op.errorTypes.includes(event.data.type)) {
          clearTimeout(timeout);
          window.removeEventListener('message', handler);
          reject(new Error(`${op.name} failed`));
        }
      };

      window.addEventListener('message', handler);
      this.iframe.contentWindow.postMessage(op.message, '*');
    });
  }

  updateProgress(current, total, currentOp) {
    const percent = Math.round((current / total) * 100);
    const progressBar = document.getElementById('progress-bar');
    const progressText = document.getElementById('progress-text');

    if (progressBar) progressBar.style.width = `${percent}%`;
    if (progressText) progressText.textContent = `${currentOp} (${current}/${total})`;
  }
}

// Usage example
async function saveThenExportThenClose() {
  const batch = new BatchOperationManager('seatmap-editor');

  batch.addOperation(
    'Save Schema',
    { type: 'SAVE_REQUEST' },
    'SCHEMA_UPDATE_SUCCESS',
    'SCHEMA_UPDATE_ERROR',
  );

  batch.addOperation('Export SVG', { type: 'FETCH_SVG' }, 'SVG_RESPONSE', 'SVG_RESPONSE');

  try {
    await batch.executeAll();
    console.log('All operations completed successfully');
    setTimeout(() => closeEditor(), 1000);
  } catch (error) {
    alert('Operation failed: ' + error.message);
  }
}

Поддержка TypeScript

Определения типов для полезных нагрузок сообщений:

// Incoming message types (Parent → Editor)
type IncomingMessage = { type: 'SAVE_REQUEST' } | { type: 'FETCH_SVG' } | 'SAVE'; // Legacy format

// Outgoing message types (Editor → Parent)
type OutgoingMessage =
  | { type: 'SCHEMA_CHANGED'; hasChanges: boolean }
  | { type: 'SCHEMA_UPDATE_SUCCESS' }
  | { type: 'SCHEMA_UPDATE_ERROR' }
  | { type: 'SEATMAP_UPDATE_SUCCESS' }
  | { type: 'SEATMAP_UPDATE_ERROR' }
  | { type: 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS' }
  | { type: 'UPDATE_PRICE_ASSIGNMENTS_ERROR'; payload: unknown }
  | { type: 'PUBLISH_REQUEST'; payload: ISchemaSettings }
  | { type: 'SVG_RESPONSE'; success: true; svg: string }
  | { type: 'SVG_RESPONSE'; success: false; error: string };

// Schema settings interface (simplified)
interface ISchemaSettings {
  id?: number;
  venueId: number;
  name: string;
  draft: boolean;
  // ... other properties
}

// Event listener with types
window.addEventListener('message', (event: MessageEvent<OutgoingMessage>) => {
  const data = event.data;

  if (data.type === 'SVG_RESPONSE') {
    if (data.success) {
      const svg: string = data.svg;
    } else {
      const error: string = data.error;
    }
  }
});

Рекомендации

1. Всегда проверяйте origin сообщений

window.addEventListener('message', (event) => {
  const allowedOrigins = ['https://editor.seatmap.pro', 'https://editor.seatmap.dev'];

  if (!allowedOrigins.includes(event.origin)) {
    return;
  }

  handleMessage(event.data);
});

2. Обрабатывайте все сообщения, связанные с сохранением

Полная операция сохранения может вызвать до 3 разных сообщений об успехе:

let saveResults = { schema: false, seatmap: false, prices: false };

function checkAllSaved() {
  return saveResults.schema && saveResults.seatmap && saveResults.prices;
}

3. Реализуйте таймауты для операций

const timeout = setTimeout(() => {
  console.error('Save operation timed out');
  resetSaveState();
}, 30000); // 30 second timeout

// Clear timeout when operation completes

4. Отслеживайте состояние операций

const operationState = {
  saveInProgress: false,
  publishInProgress: false,
  exportInProgress: false,
};

// Prevent duplicate operations
if (operationState.saveInProgress) {
  console.warn('Save already in progress');
  return;
}

5. Обеспечивайте обратную связь для пользователя

// Show loading indicators
// Display success/error notifications
// Update UI state based on messages
// Log operations for debugging

6. Обрабатывайте несохранённые изменения

window.addEventListener('beforeunload', (event) => {
  if (hasUnsavedChanges) {
    event.preventDefault();
    event.returnValue = '';
  }
});

7. Дождитесь сохранения схемы перед экспортом

async function saveAndExport() {
  // Save first
  await save();

  // Then export
  exportSvg();
}

Дополнительные ресурсы

Поддержка

По вопросам или проблемам с интеграцией iframe:

  • Изучите существующие тестовые файлы с рабочими примерами
  • Проверьте консоль браузера на наличие сообщений об ошибках
  • Свяжитесь со службой поддержки, указав конкретные детали ошибки и информацию о браузере