Comunicación mediante IFrame

API de comunicación mediante Iframe

Este documento describe el protocolo de comunicación basado en postMessage para embeber el Seatmap Editor como un iframe en aplicaciones padre.

Resumen

El Seatmap Editor admite comunicación bidireccional a través de la API postMessage del navegador cuando se embebe como un iframe. Esto permite a las aplicaciones padre:

  • Solicitar al editor que guarde el esquema actual
  • Obtener la exportación SVG del recinto actual
  • Recibir notificaciones sobre cambios del esquema y el estado de guardado
  • Recibir notificaciones cuando se publica un esquema
  • Monitorear el progreso de guardado de los distintos componentes (metadatos del esquema, gráficos del recinto, asignaciones de precios)

Configuración

Embeber el Editor

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

Listener de eventos de mensaje

Configura un listener de mensajes en la ventana padre para recibir mensajes del editor:

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);
  }
});

Referencia de tipos de mensaje

Resumen

Parent → Editor (entrantes):

Message Type Descripción Legacy Format
SAVE_REQUEST Solicita al editor guardar el esquema actual 'SAVE'
FETCH_SVG Solicita la exportación SVG del esquema actual -
SET_LANGUAGE Cambia el idioma de la interfaz del editor -

Editor → Parent (salientes):

Message Type Descripción Payload
SCHEMA_CHANGED Notifica cuando el esquema tiene cambios sin guardar { hasChanges: boolean }
SCHEMA_UPDATE_SUCCESS Metadatos del esquema guardados correctamente -
SCHEMA_UPDATE_ERROR Falló el guardado de los metadatos del esquema -
SEATMAP_UPDATE_SUCCESS Gráficos/formas del recinto guardados correctamente -
SEATMAP_UPDATE_ERROR Falló el guardado de los gráficos/formas del recinto { payload: unknown }
UPDATE_PRICE_ASSIGNMENTS_SUCCESS Asignaciones de precios guardadas correctamente -
UPDATE_PRICE_ASSIGNMENTS_ERROR Falló el guardado de las asignaciones de precios { payload: unknown }
PRICES_CHANGED Las asignaciones de precios tienen cambios sin guardar { hasPriceChanges: boolean }
PRICE_CREATED Se creó un precio en la lista de precios del evento { price: { id, name } }
PRICE_UPDATED Se renombró un precio en la lista de precios del evento { price: { id, name } }
PRICE_DELETED Se eliminó un precio de la lista de precios del evento { priceId: number }
PUBLISH_REQUEST El usuario hizo clic en el botón de publicar { payload: ISchemaSettings }
SVG_RESPONSE Respuesta a la solicitud FETCH_SVG (éxito o error) { success, svg?, error? }

Parent → Editor (mensajes entrantes)

Mensajes enviados desde la ventana padre al iframe del editor.

SAVE_REQUEST

Activa el guardado del esquema actual por parte del editor. Esta operación persiste todos los cambios realizados en el diseño del recinto, incluyendo asientos, secciones, filas, zonas de precios y gráficos de fondo.

Qué se guarda:

  • Diseño y estructura del recinto
  • Posiciones, etiquetas y propiedades de los asientos
  • Definiciones y límites de las secciones
  • Configuraciones y numeración de las filas
  • Asignaciones de zonas de precios
  • SVG/imágenes de fondo y su posicionamiento
  • Formas y etiquetas personalizadas
  • Ajustes del viewbox del cliente

Formato:

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

Formato legacy (obsoleto):

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

Mensajes de respuesta:

La operación de guardado puede activar varios mensajes de respuesta según lo que se haya guardado:

  • SCHEMA_UPDATE_SUCCESS - Metadatos y ajustes del esquema guardados correctamente
  • SCHEMA_UPDATE_ERROR - Falló el guardado del esquema
  • SEATMAP_UPDATE_SUCCESS - Gráficos/formas del recinto guardados correctamente
  • SEATMAP_UPDATE_ERROR - Falló el guardado de los gráficos/formas del recinto
  • UPDATE_PRICE_ASSIGNMENTS_SUCCESS - Asignaciones de precios guardadas correctamente
  • UPDATE_PRICE_ASSIGNMENTS_ERROR - Falló el guardado de las asignaciones de precios

Ejemplo básico de guardado:

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');
  }
});

Ejemplo completo de manejador de guardado:

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');
  }
}

Guardado con indicador de carga:

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);
}

Implementación de autoguardado:

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

Solicita la exportación SVG del esquema actual. El esquema debe estar guardado antes de la exportación.

Formato:

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

Mensaje de respuesta:

  • SVG_RESPONSE - Contiene el estado de éxito, los datos SVG o un mensaje de error

Ejemplo:

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

Cambia el idioma de la interfaz del editor en tiempo de ejecución. Úsalo para mantener el editor incrustado sincronizado con el idioma de tu aplicación anfitriona sin recargar el iframe.

Formato:

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

Códigos de idioma admitidos: en, ru, de, es, fr, it, zh, pl. Los códigos no admitidos o mal formados se ignoran y se conserva el idioma actual.

Para fijar el idioma cuando el editor carga por primera vez (antes de cualquier interacción del usuario), añade el parámetro de consulta language a la URL del iframe:

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

Ejemplo:

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

// Cambiar el editor a polaco
iframe.contentWindow.postMessage({ type: 'SET_LANGUAGE', language: 'pl' }, '*');

Editor → Parent (mensajes salientes)

Mensajes enviados desde el iframe del editor a la ventana padre.

SCHEMA_CHANGED

Notifica al padre cuando el esquema tiene cambios sin guardar.

Payload:

{
  type: 'SCHEMA_CHANGED',
  hasChanges: boolean
}

Ejemplo:

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

Se envía después de que una operación de guardado del esquema se completa correctamente.

Payload:

{
  type: 'SCHEMA_UPDATE_SUCCESS';
}

SCHEMA_UPDATE_ERROR

Se envía cuando falla una operación de guardado del esquema.

Payload:

{
  type: 'SCHEMA_UPDATE_ERROR';
}

SEATMAP_UPDATE_SUCCESS

Se envía después de que los gráficos/formas del recinto se guardan correctamente.

Payload:

{
  type: 'SEATMAP_UPDATE_SUCCESS';
}

SEATMAP_UPDATE_ERROR

Se envía cuando falla el guardado de los gráficos/formas del recinto.

Payload:

{
  type: 'SEATMAP_UPDATE_ERROR';
}

SVG_RESPONSE

Respuesta a una solicitud FETCH_SVG que contiene el SVG exportado o información de error.

Payload de éxito:

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

Payload de error:

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

Mensajes de error comunes:

  • "Schema must be saved before export" - El esquema aún no se ha guardado
  • Mensajes de error de exportación localizados provenientes de la API

UPDATE_PRICE_ASSIGNMENTS_SUCCESS

Se envía después de que las actualizaciones de asignaciones de precios se completan correctamente.

Payload:

{
  type: 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS';
}

UPDATE_PRICE_ASSIGNMENTS_ERROR

Se envía cuando fallan las actualizaciones de asignaciones de precios.

Payload:

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

PRICES_CHANGED

Notifica a la ventana padre cuando las asignaciones de precios tienen cambios sin guardar, de forma análoga a SCHEMA_CHANGED para los precios. Se envía con hasPriceChanges: true cuando el usuario asigna o quita precios de asientos o secciones, y con hasPriceChanges: false una vez que las asignaciones se guardan.

Payload:

{
  type: 'PRICES_CHANGED',
  hasPriceChanges: boolean
}

PRICE_CREATED

Se envía inmediatamente después de crear un nuevo precio en la lista de precios del evento.

A diferencia de las asignaciones de precios, la lista de precios pertenece al evento y se persiste de inmediato: crear, renombrar o eliminar un precio llama al backend al instante y no espera a la acción Save del esquema. Los mensajes PRICE_CREATED, PRICE_UPDATED y PRICE_DELETED permiten a la aplicación padre mantener su propio catálogo de precios sincronizado con estos cambios inmediatos.

Payload:

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

Ejemplo:

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

Se envía inmediatamente después de renombrar un precio existente en la lista de precios del evento.

Payload:

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

PRICE_DELETED

Se envía inmediatamente después de eliminar un precio de la lista de precios del evento.

Payload:

{
  type: 'PRICE_DELETED',
  priceId: number
}

PUBLISH_REQUEST

Se envía cuando el usuario hace clic en el botón “Publish” del editor. Esto indica que el esquema se está publicando del modo borrador al modo en vivo.

Payload:

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

Ejemplo:

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.)
  }
});

Notas importantes:

  • Este mensaje se envía cuando el usuario inicia la publicación, pero antes de que se complete la operación en el servidor
  • El esquema se guardará igualmente a través del flujo de guardado normal, activando SCHEMA_UPDATE_SUCCESS o SCHEMA_UPDATE_ERROR
  • El padre debe esperar a SCHEMA_UPDATE_SUCCESS para confirmar que la operación de publicación se completó correctamente

Ejemplo de flujo de publicación:

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;
  }
});

Ejemplo de integración completo

<!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>

Manejo de errores

Escenarios comunes

Esquema no guardado

Al solicitar la exportación SVG sin guardar primero:

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

Solución: Activa primero un guardado y luego solicita el 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);
}

Flujo completo de «guardar y luego exportar»:

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);
}

Errores de API

Errores de red o del servidor durante la exportación:

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

Solución: Muestra un mensaje de error comprensible para el usuario y ofrece la opción de reintentar.

Consideraciones sobre el tamaño de los mensajes

Las exportaciones SVG pueden ser grandes, especialmente para recintos complejos:

  • Recintos pequeños: 10KB - 100KB
  • Recintos medianos: 100KB - 1MB
  • Recintos grandes: 1MB - 10MB+

La API postMessage admite mensajes de hasta ~64MB en los navegadores modernos, pero las cargas útiles muy grandes pueden causar problemas de rendimiento. Para recintos con gráficos extremadamente complejos, considera:

  • Usar el endpoint directo de la API (/api/export/${schemaId}/) en su lugar
  • Descargar mediante procesamiento del lado del servidor
  • Implementar indicadores de progreso para el usuario

Pruebas

Archivos de prueba

El repositorio incluye archivos HTML de prueba para desarrollo y depuración:

  1. public/sso_test.html - Página de prueba con todas las funciones:

    • Flujo de autenticación SSO
    • Embebido del iframe en una ventana modal
    • Botón de solicitud de guardado
    • Panel de registro de mensajes
    • Visualización de mensajes en tiempo real
  2. public/iframe_modal_test.html - Prueba modal simple con:

    • Embebido básico del iframe
    • Registro de eventos de mensaje
    • Seguimiento de cambios del esquema

Pruebas locales

# Start development server
yarn start

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

Consideraciones de seguridad

Verificación del origin

Verifica siempre el origin de los mensajes recibidos en producción:

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 de destino

Al enviar mensajes al iframe, especifica el origin de destino:

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

Autenticación

El editor requiere autenticación JWT. Pasa los tokens a través de los parámetros de la URL:

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

Asegúrate de que los tokens se transmitan de forma segura y tengan tiempos de expiración apropiados.

Resolución de problemas

Los mensajes no se reciben

  1. Comprueba que el iframe está cargado:

    iframe.addEventListener('load', () => {
      console.log('Iframe loaded, ready for messages');
    });
    
  2. Verifica el acceso a contentWindow:

    if (!iframe.contentWindow) {
      console.error('Cannot access iframe contentWindow');
    }
    
  3. Comprueba las restricciones de cross-origin:

    • Asegúrate de que las cabeceras CORS estén correctamente configuradas
    • Usa la consola del navegador para comprobar errores de seguridad

El guardado no funciona

Causas comunes:

  1. Problemas de autenticación

    • Token expirado o inválido
    • El usuario no tiene permisos de escritura
    • La sesión expiró
  2. Errores de validación

    • Los datos del esquema están incompletos o son inválidos
    • Faltan campos obligatorios
    • Errores de formato de datos
  3. Problemas de red

    • El endpoint de la API es inaccesible
    • Restricciones de CORS
    • El firewall bloquea las solicitudes
  4. Problemas de estado

    • No hay cambios para guardar
    • El editor no está completamente cargado
    • Operaciones de guardado concurrentes

Pasos de depuración:

// 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

Lista de verificación de la operación de guardado:

  • El iframe del editor está completamente cargado
  • El usuario está autenticado (token presente)
  • El usuario tiene permisos de edición para el recinto
  • El esquema tiene cambios sin guardar (escucha SCHEMA_CHANGED)
  • No hay otra operación de guardado en curso
  • La conectividad de red es estable

Falla la exportación SVG

Causas comunes:

  • El esquema no está guardado (la más común)
  • Problemas de conectividad de red
  • Errores de exportación del lado del servidor
  • Datos del esquema inválidos

Diagrama de flujo de mensajes

Flujo de la operación de guardado

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

Flujo de la operación de publicación

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

Flujo de exportación 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)

Flujos de trabajo comunes

Manejador de mensajes integral

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?';
  }
});

Guardar y cerrar la ventana modal

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();
  }
}

Autoguardado periódico con visualización de estado

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();
});

Flujo «guardar — exportar — descargar — cerrar»

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);
}

Operaciones por lotes con seguimiento de progreso

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);
  }
}

Soporte para TypeScript

Definiciones de tipos para las cargas útiles de los mensajes:

// 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;
    }
  }
});

Buenas prácticas

1. Verifica siempre el origin de los mensajes

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

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

  handleMessage(event.data);
});

2. Maneja todos los mensajes relacionados con el guardado

Una operación de guardado completa puede activar hasta 3 mensajes de éxito distintos:

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

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

3. Implementa timeouts para las operaciones

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

// Clear timeout when operation completes

4. Realiza un seguimiento del estado de las operaciones

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

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

5. Proporciona retroalimentación al usuario

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

6. Maneja los cambios sin guardar

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

7. Espera el guardado del esquema antes de exportar

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

  // Then export
  exportSvg();
}

Recursos adicionales

Soporte

Para preguntas o problemas con la integración del iframe:

  • Consulta los archivos de prueba existentes para ver ejemplos funcionales
  • Revisa la consola del navegador en busca de mensajes de error
  • Contacta con soporte indicando los detalles específicos del error y la información del navegador