From 179a83e9cdab4775ffb63c906a4a74cfe310f429 Mon Sep 17 00:00:00 2001 From: FrankZamora Date: Wed, 10 Dec 2025 15:48:20 -0600 Subject: [PATCH] feat(js): implement intersection observer lazy loading for adsense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add per-slot lazy loading with Intersection Observer API - Implement fill detection via MutationObserver and data-ad-status - Add configurable rootMargin and fillTimeout from database - Generate dynamic CSS based on lazy_loading_enabled setting - Add legacy mode fallback for browsers without IO support - Include backup of previous implementation (adsense-loader.legacy.js) - Add OpenSpec documentation with test plan (72 tests verified) Schema changes: - Add lazy_loading_enabled (boolean, default: true) - Add lazy_rootmargin (select: 0-500px, default: 200) - Add lazy_fill_timeout (select: 3000-10000ms, default: 5000) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../AdsensePlacementFieldMapper.php | 5 + .../Ui/AdsensePlacementFormBuilder.php | 49 + Assets/Js/adsense-loader.js | 691 +++++++--- Assets/Js/adsense-loader.legacy.js | 306 +++++ Inc/enqueue-scripts.php | 12 + .../Ui/AdsensePlacementRenderer.php | 53 +- Schemas/adsense-placement.json | 37 +- .../refactor-adsense-lazy-loading/design.md | 274 ++++ .../refactor-adsense-lazy-loading/proposal.md | 54 + .../sanity-tests.md | 284 +++++ .../schema-changes.md | 111 ++ .../specs/adsense-lazy-loading/spec.md | 360 ++++++ .../refactor-adsense-lazy-loading/tasks.md | 150 +++ .../test-plan.md | 1118 +++++++++++++++++ 14 files changed, 3303 insertions(+), 201 deletions(-) create mode 100644 Assets/Js/adsense-loader.legacy.js create mode 100644 openspec/changes/refactor-adsense-lazy-loading/design.md create mode 100644 openspec/changes/refactor-adsense-lazy-loading/proposal.md create mode 100644 openspec/changes/refactor-adsense-lazy-loading/sanity-tests.md create mode 100644 openspec/changes/refactor-adsense-lazy-loading/schema-changes.md create mode 100644 openspec/changes/refactor-adsense-lazy-loading/specs/adsense-lazy-loading/spec.md create mode 100644 openspec/changes/refactor-adsense-lazy-loading/tasks.md create mode 100644 openspec/changes/refactor-adsense-lazy-loading/test-plan.md diff --git a/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php b/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php index 6170f86c..540685ce 100644 --- a/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php +++ b/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php @@ -56,6 +56,11 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface 'adsense-placementRailFormat' => ['group' => 'behavior', 'attribute' => 'rail_format'], 'adsense-placementRailTopOffset' => ['group' => 'behavior', 'attribute' => 'rail_top_offset'], + // BEHAVIOR (Lazy Loading) + 'adsense-placementLazyLoadingEnabled' => ['group' => 'behavior', 'attribute' => 'lazy_loading_enabled'], + 'adsense-placementLazyRootmargin' => ['group' => 'behavior', 'attribute' => 'lazy_rootmargin'], + 'adsense-placementLazyFillTimeout' => ['group' => 'behavior', 'attribute' => 'lazy_fill_timeout'], + // LAYOUT (Archive/Global locations + formats) 'adsense-placementArchiveTopEnabled' => ['group' => 'layout', 'attribute' => 'archive_top_enabled'], 'adsense-placementArchiveBetweenEnabled' => ['group' => 'layout', 'attribute' => 'archive_between_enabled'], diff --git a/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php b/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php index fc91f591..82a227b6 100644 --- a/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php +++ b/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php @@ -1182,6 +1182,55 @@ final class AdsensePlacementFormBuilder $delayTimeout = $this->renderer->getFieldValue($cid, 'forms', 'delay_timeout', '5000'); $html .= $this->buildTextInput($cid . 'DelayTimeout', 'Timeout de delay (ms)', $delayTimeout); + // Lazy Loading settings + $html .= '
'; + $html .= '

'; + $html .= ' '; + $html .= ' Lazy Loading (Intersection Observer)'; + $html .= ' Nuevo'; + $html .= '

'; + $html .= '
'; + $html .= ' '; + $html .= ' Carga anuncios individualmente al entrar al viewport. Mejora fill rate y reduce CLS.'; + $html .= '
'; + + $lazyEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'lazy_loading_enabled', true); + $html .= $this->buildSwitch($cid . 'LazyLoadingEnabled', 'Activar Lazy Loading', $lazyEnabled, 'bi-eye'); + + $html .= '
'; + $html .= '
'; + $lazyRootmargin = $this->renderer->getFieldValue($cid, 'behavior', 'lazy_rootmargin', '200'); + $html .= $this->buildSelect($cid . 'LazyRootmargin', 'Pre-carga (px)', + (string)$lazyRootmargin, + [ + '0' => '0px (sin pre-carga)', + '100' => '100px', + '200' => '200px (recomendado)', + '300' => '300px', + '400' => '400px', + '500' => '500px' + ] + ); + $html .= '
'; + $html .= '
'; + $lazyFillTimeout = $this->renderer->getFieldValue($cid, 'behavior', 'lazy_fill_timeout', '5000'); + $html .= $this->buildSelect($cid . 'LazyFillTimeout', 'Timeout fill (ms)', + (string)$lazyFillTimeout, + [ + '3000' => '3 segundos', + '5000' => '5 segundos (recomendado)', + '7000' => '7 segundos', + '10000' => '10 segundos' + ] + ); + $html .= '
'; + $html .= '
'; + + $html .= '
'; + $html .= ' '; + $html .= ' Nota: Cambios requieren vaciar cache (Redis, W3TC) para aplicarse.'; + $html .= '
'; + $html .= ' '; $html .= ''; diff --git a/Assets/Js/adsense-loader.js b/Assets/Js/adsense-loader.js index 55daf56b..985a02d5 100644 --- a/Assets/Js/adsense-loader.js +++ b/Assets/Js/adsense-loader.js @@ -1,306 +1,639 @@ /** - * Cargador Retrasado de AdSense + * AdSense Lazy Loader con Intersection Observer * - * Este script retrasa la carga de Google AdSense hasta que haya interacción - * del usuario o se cumpla un timeout, mejorando el rendimiento de carga inicial. + * Carga anuncios AdSense individualmente cuando entran al viewport, + * detecta si reciben contenido, y oculta slots vacios. * * @package ROI_Theme - * @since 1.0.0 + * @since 1.5.0 + * @version 2.0.0 - Refactorizado con Intersection Observer */ (function() { 'use strict'; - // Configuración - const CONFIG = { - timeout: 5000, // Timeout de fallback en milisegundos - loadedClass: 'adsense-loaded', - debug: true // TEMPORAL: Habilitado para diagnóstico + // ========================================================================= + // CONFIGURACION + // ========================================================================= + + /** + * Configuracion por defecto, sobrescrita por window.roiAdsenseConfig + */ + var DEFAULT_CONFIG = { + lazyEnabled: true, + rootMargin: '200px 0px', + fillTimeout: 5000, + debug: false }; - // Estado - let adsenseLoaded = false; - let loadTimeout = null; + /** + * Obtiene configuracion desde wp_localize_script o usa defaults + */ + function getConfig() { + var wpConfig = window.roiAdsenseConfig || {}; + return { + lazyEnabled: typeof wpConfig.lazyEnabled !== 'undefined' ? wpConfig.lazyEnabled : DEFAULT_CONFIG.lazyEnabled, + rootMargin: wpConfig.rootMargin || DEFAULT_CONFIG.rootMargin, + fillTimeout: typeof wpConfig.fillTimeout !== 'undefined' ? parseInt(wpConfig.fillTimeout, 10) : DEFAULT_CONFIG.fillTimeout, + debug: typeof wpConfig.debug !== 'undefined' ? wpConfig.debug : DEFAULT_CONFIG.debug + }; + } + + var CONFIG = getConfig(); + + // ========================================================================= + // ESTADO GLOBAL + // ========================================================================= + + var libraryLoaded = false; + var libraryLoading = false; + var libraryLoadFailed = false; + var loadRetryCount = 0; + var MAX_LOAD_RETRIES = 1; + var RETRY_DELAY = 2000; + + /** @type {IntersectionObserver|null} */ + var slotObserver = null; + + /** @type {Map} */ + var fillObservers = new Map(); + + /** @type {Map} */ + var fillTimeouts = new Map(); + + /** @type {Set} */ + var activatedSlots = new Set(); + + /** @type {Array} */ + var pendingActivations = []; + + // ========================================================================= + // LOGGING + // ========================================================================= /** - * Registra mensajes de debug si el modo debug está habilitado - * @param {string} message - El mensaje a registrar + * Log condicional basado en CONFIG.debug + * @param {string} message + * @param {string} [level='log'] - 'log', 'warn', 'error' */ - function debugLog(message) { - if (CONFIG.debug && typeof console !== 'undefined') { - console.log('[AdSense Loader] ' + message); + function debugLog(message, level) { + if (!CONFIG.debug || typeof console === 'undefined') { + return; + } + level = level || 'log'; + var prefix = '[AdSense Lazy] '; + if (level === 'error') { + console.error(prefix + message); + } else if (level === 'warn') { + console.warn(prefix + message); + } else { + console.log(prefix + message); } } + // ========================================================================= + // DETECCION DE SOPORTE + // ========================================================================= + /** - * Carga los scripts de AdSense e inicializa los ads + * Verifica si el navegador soporta Intersection Observer */ - function loadAdSense() { - // Prevenir múltiples cargas - if (adsenseLoaded) { - debugLog('AdSense ya fue cargado, omitiendo...'); + function hasIntersectionObserverSupport() { + return typeof window.IntersectionObserver !== 'undefined'; + } + + /** + * Verifica si el navegador soporta MutationObserver + */ + function hasMutationObserverSupport() { + return typeof window.MutationObserver !== 'undefined'; + } + + // ========================================================================= + // CARGA DE BIBLIOTECA ADSENSE + // ========================================================================= + + /** + * Carga la biblioteca adsbygoogle.js + * @param {Function} onSuccess + * @param {Function} onError + */ + function loadAdSenseLibrary(onSuccess, onError) { + if (libraryLoaded) { + debugLog('Biblioteca ya cargada'); + onSuccess(); return; } - adsenseLoaded = true; - debugLog('Cargando scripts de AdSense...'); - - // Limpiar el timeout si existe - if (loadTimeout) { - clearTimeout(loadTimeout); - loadTimeout = null; + if (libraryLoading) { + debugLog('Biblioteca en proceso de carga, encolando callback'); + pendingActivations.push(onSuccess); + return; } - // Remover event listeners para prevenir múltiples triggers - removeEventListeners(); + libraryLoading = true; + debugLog('Cargando biblioteca adsbygoogle.js...'); - // Cargar etiquetas de script de AdSense y esperar a que cargue - // IMPORTANTE: Debe esperar a que adsbygoogle.js cargue antes de ejecutar push - loadAdSenseScripts(function() { - debugLog('Biblioteca AdSense cargada, ejecutando push scripts...'); + var scriptTags = document.querySelectorAll('script[data-adsense-script]'); + if (scriptTags.length === 0) { + debugLog('No se encontro script[data-adsense-script]', 'warn'); + libraryLoading = false; + onError(); + return; + } - // Ejecutar scripts de push de AdSense - executeAdSensePushScripts(); + var oldScript = scriptTags[0]; + var newScript = document.createElement('script'); + newScript.src = oldScript.src; + newScript.async = true; - // Agregar clase loaded al body - document.body.classList.add(CONFIG.loadedClass); + if (oldScript.getAttribute('crossorigin')) { + newScript.crossOrigin = oldScript.getAttribute('crossorigin'); + } - debugLog('Carga de AdSense completada'); - }); - } + newScript.onload = function() { + debugLog('Biblioteca cargada exitosamente'); + libraryLoaded = true; + libraryLoading = false; + window.adsbygoogle = window.adsbygoogle || []; - /** - * Encuentra y carga todas las etiquetas de script de AdSense retrasadas - * @param {Function} callback - Función a ejecutar cuando la biblioteca cargue - */ - function loadAdSenseScripts(callback) { - const delayedScripts = document.querySelectorAll('script[data-adsense-script]'); - - if (delayedScripts.length === 0) { - debugLog('No se encontraron scripts retrasados de AdSense'); - // Ejecutar callback de todas formas (puede haber ads sin script principal) - if (typeof callback === 'function') { + // Ejecutar callbacks pendientes + onSuccess(); + while (pendingActivations.length > 0) { + var callback = pendingActivations.shift(); callback(); } + }; + + newScript.onerror = function() { + debugLog('Error cargando biblioteca (intento ' + (loadRetryCount + 1) + ')', 'error'); + libraryLoading = false; + + if (loadRetryCount < MAX_LOAD_RETRIES) { + loadRetryCount++; + debugLog('Reintentando en ' + RETRY_DELAY + 'ms...'); + setTimeout(function() { + loadAdSenseLibrary(onSuccess, onError); + }, RETRY_DELAY); + } else { + debugLog('Maximo de reintentos alcanzado', 'error'); + libraryLoadFailed = true; + onError(); + } + }; + + oldScript.parentNode.replaceChild(newScript, oldScript); + } + + /** + * Marca todos los slots como error cuando la biblioteca falla + */ + function markAllSlotsAsError() { + var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]'); + slots.forEach(function(slot) { + slot.classList.add('roi-ad-error'); + cleanupSlot(slot); + }); + debugLog('Todos los slots marcados como error', 'error'); + } + + // ========================================================================= + // ACTIVACION DE SLOTS + // ========================================================================= + + /** + * Activa un slot individual ejecutando adsbygoogle.push() + * @param {Element} slot + */ + function activateSlot(slot) { + if (activatedSlots.has(slot)) { + debugLog('Slot ya activado, omitiendo'); return; } - debugLog('Se encontraron ' + delayedScripts.length + ' script(s) retrasado(s) de AdSense'); + if (libraryLoadFailed) { + debugLog('Biblioteca fallida, marcando slot como error'); + slot.classList.add('roi-ad-error'); + return; + } - var scriptsLoaded = 0; - var totalScripts = delayedScripts.length; + activatedSlots.add(slot); - delayedScripts.forEach(function(oldScript) { - const newScript = document.createElement('script'); - - // Copiar atributos - if (oldScript.src) { - newScript.src = oldScript.src; + var doActivation = function() { + var ins = slot.querySelector('ins.adsbygoogle'); + if (!ins) { + debugLog('No se encontro en slot', 'warn'); + slot.classList.add('roi-ad-empty'); + return; } - // Establecer atributo async - newScript.async = true; + debugLog('Activando slot: ' + (ins.getAttribute('data-ad-slot') || 'unknown')); - // Copiar crossorigin si está presente - if (oldScript.getAttribute('crossorigin')) { - newScript.crossorigin = oldScript.getAttribute('crossorigin'); + // Ejecutar push + try { + window.adsbygoogle = window.adsbygoogle || []; + window.adsbygoogle.push({}); + } catch (e) { + debugLog('Error en push: ' + e.message, 'error'); + slot.classList.add('roi-ad-error'); + return; } - // Esperar a que cargue antes de ejecutar callback - newScript.onload = function() { - scriptsLoaded++; - debugLog('Script cargado (' + scriptsLoaded + '/' + totalScripts + '): ' + newScript.src.substring(0, 50) + '...'); - if (scriptsLoaded === totalScripts && typeof callback === 'function') { - callback(); - } - }; + // Iniciar observacion de llenado + startFillDetection(slot, ins); + }; - newScript.onerror = function() { - scriptsLoaded++; - debugLog('Error cargando script: ' + newScript.src); - if (scriptsLoaded === totalScripts && typeof callback === 'function') { - callback(); - } - }; + // Si la biblioteca ya cargo, activar inmediatamente + if (libraryLoaded) { + doActivation(); + } else { + // Cargar biblioteca y luego activar + loadAdSenseLibrary(doActivation, function() { + markAllSlotsAsError(); + }); + } + } - // Reemplazar script viejo con el nuevo - oldScript.parentNode.replaceChild(newScript, oldScript); + // ========================================================================= + // DETECCION DE LLENADO + // ========================================================================= + + /** + * Inicia la deteccion de llenado para un slot + * @param {Element} slot + * @param {Element} ins + */ + function startFillDetection(slot, ins) { + // Verificar inmediatamente si ya tiene contenido + if (checkFillStatus(slot, ins)) { + return; + } + + // Configurar timeout + var timeoutId = setTimeout(function() { + debugLog('Timeout de llenado alcanzado'); + markSlotEmpty(slot); + }, CONFIG.fillTimeout); + fillTimeouts.set(slot, timeoutId); + + // Configurar MutationObserver si hay soporte + if (hasMutationObserverSupport()) { + var mutationObserver = new MutationObserver(function(mutations) { + if (checkFillStatus(slot, ins)) { + // Ya procesado en checkFillStatus + } + }); + + mutationObserver.observe(ins, { + attributes: true, + childList: true, + subtree: true, + attributeFilter: ['data-ad-status'] + }); + + fillObservers.set(slot, mutationObserver); + } else { + // Sin MutationObserver, solo usar timeout + debugLog('Sin soporte MutationObserver, usando solo timeout'); + } + } + + /** + * Verifica el estado de llenado de un slot + * @param {Element} slot + * @param {Element} ins + * @returns {boolean} true si el estado fue determinado (filled o empty) + */ + function checkFillStatus(slot, ins) { + // Criterio 1: data-ad-status attribute + var status = ins.getAttribute('data-ad-status'); + if (status === 'filled') { + debugLog('Slot llenado (data-ad-status=filled)'); + markSlotFilled(slot); + return true; + } + if (status === 'unfilled') { + debugLog('Slot vacio (data-ad-status=unfilled)'); + markSlotEmpty(slot); + return true; + } + + // Criterio 2 (fallback): iframe presente + var iframe = ins.querySelector('iframe'); + if (iframe) { + debugLog('Slot llenado (iframe detectado)'); + markSlotFilled(slot); + return true; + } + + // Criterio 3 (fallback): div con id presente + var divWithId = ins.querySelector('div[id]'); + if (divWithId) { + debugLog('Slot llenado (div con id detectado)'); + markSlotFilled(slot); + return true; + } + + return false; + } + + /** + * Marca un slot como llenado + * @param {Element} slot + */ + function markSlotFilled(slot) { + slot.classList.remove('roi-ad-empty', 'roi-ad-error'); + slot.classList.add('roi-ad-filled'); + cleanupSlot(slot); + } + + /** + * Marca un slot como vacio + * @param {Element} slot + */ + function markSlotEmpty(slot) { + slot.classList.remove('roi-ad-filled', 'roi-ad-error'); + slot.classList.add('roi-ad-empty'); + cleanupSlot(slot); + } + + /** + * Limpia observadores y timeouts de un slot + * @param {Element} slot + */ + function cleanupSlot(slot) { + // Limpiar timeout + if (fillTimeouts.has(slot)) { + clearTimeout(fillTimeouts.get(slot)); + fillTimeouts.delete(slot); + } + + // Limpiar MutationObserver + if (fillObservers.has(slot)) { + fillObservers.get(slot).disconnect(); + fillObservers.delete(slot); + } + + // Dejar de observar con IntersectionObserver + if (slotObserver) { + slotObserver.unobserve(slot); + } + } + + // ========================================================================= + // INTERSECTION OBSERVER + // ========================================================================= + + /** + * Inicializa el Intersection Observer para slots + */ + function initIntersectionObserver() { + if (!hasIntersectionObserverSupport()) { + debugLog('Sin soporte Intersection Observer, usando modo legacy', 'warn'); + return false; + } + + var options = { + root: null, + rootMargin: CONFIG.rootMargin, + threshold: 0 + }; + + slotObserver = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (entry.isIntersecting) { + var slot = entry.target; + debugLog('Slot entro al viewport'); + activateSlot(slot); + } + }); + }, options); + + debugLog('Intersection Observer inicializado con rootMargin: ' + CONFIG.rootMargin); + return true; + } + + /** + * Observa todos los slots lazy en la pagina + */ + function observeAllSlots() { + var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]'); + debugLog('Encontrados ' + slots.length + ' slots para observar'); + + slots.forEach(function(slot) { + if (!activatedSlots.has(slot)) { + slotObserver.observe(slot); + } }); } /** - * Ejecuta scripts de push de AdSense retrasados + * Observa nuevos slots agregados dinamicamente */ - function executeAdSensePushScripts() { - const delayedPushScripts = document.querySelectorAll('script[data-adsense-push]'); + function observeNewSlots() { + var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]'); + var newCount = 0; - if (delayedPushScripts.length === 0) { - debugLog('No se encontraron scripts de push retrasados de AdSense'); + slots.forEach(function(slot) { + if (!activatedSlots.has(slot)) { + slotObserver.observe(slot); + newCount++; + } + }); + + if (newCount > 0) { + debugLog('Agregados ' + newCount + ' nuevos slots al observer'); + } + } + + // ========================================================================= + // MODO LEGACY (FALLBACK) + // ========================================================================= + + /** + * Variables para modo legacy + */ + var legacyLoaded = false; + var legacyTimeout = null; + + /** + * Carga todos los ads en modo legacy (sin Intersection Observer) + */ + function loadAllAdsLegacy() { + if (legacyLoaded) { return; } + legacyLoaded = true; + debugLog('Modo legacy: Cargando todos los ads'); - debugLog('Se encontraron ' + delayedPushScripts.length + ' script(s) de push retrasado(s)'); + if (legacyTimeout) { + clearTimeout(legacyTimeout); + } + removeLegacyEventListeners(); + + loadAdSenseLibrary(function() { + executeAllPushScripts(); + }, function() { + debugLog('Error en modo legacy', 'error'); + }); + } + + /** + * Ejecuta todos los scripts de push en modo legacy + */ + function executeAllPushScripts() { + var pushScripts = document.querySelectorAll('script[data-adsense-push]'); + debugLog('Ejecutando ' + pushScripts.length + ' scripts de push'); - // Inicializar array adsbygoogle si no existe window.adsbygoogle = window.adsbygoogle || []; - delayedPushScripts.forEach(function(oldScript) { - const scriptContent = oldScript.innerHTML; - - // Crear y ejecutar nuevo script - const newScript = document.createElement('script'); - newScript.innerHTML = scriptContent; + pushScripts.forEach(function(oldScript) { + var newScript = document.createElement('script'); + newScript.innerHTML = oldScript.innerHTML; newScript.type = 'text/javascript'; - - // Reemplazar script viejo con el nuevo oldScript.parentNode.replaceChild(newScript, oldScript); }); + + document.body.classList.add('adsense-loaded'); } /** - * Manejador de eventos para interacciones del usuario + * Event handler para modo legacy */ - function handleUserInteraction() { - debugLog('Interacción del usuario detectada'); - loadAdSense(); + function handleLegacyInteraction() { + debugLog('Interaccion detectada (modo legacy)'); + loadAllAdsLegacy(); } /** - * Remueve todos los event listeners + * Agrega listeners para modo legacy */ - function removeEventListeners() { - window.removeEventListener('scroll', handleUserInteraction, { passive: true }); - window.removeEventListener('mousemove', handleUserInteraction, { passive: true }); - window.removeEventListener('touchstart', handleUserInteraction, { passive: true }); - window.removeEventListener('click', handleUserInteraction, { passive: true }); - window.removeEventListener('keydown', handleUserInteraction, { passive: true }); + function addLegacyEventListeners() { + window.addEventListener('scroll', handleLegacyInteraction, { passive: true, once: true }); + window.addEventListener('mousemove', handleLegacyInteraction, { passive: true, once: true }); + window.addEventListener('touchstart', handleLegacyInteraction, { passive: true, once: true }); + window.addEventListener('click', handleLegacyInteraction, { passive: true, once: true }); + window.addEventListener('keydown', handleLegacyInteraction, { passive: true, once: true }); } /** - * Agrega event listeners para interacciones del usuario + * Remueve listeners de modo legacy */ - function addEventListeners() { - debugLog('Agregando event listeners para interacción del usuario'); - - // Evento de scroll - cargar en primer scroll - window.addEventListener('scroll', handleUserInteraction, { passive: true, once: true }); - - // Movimiento de mouse - cargar cuando el usuario mueve el mouse - window.addEventListener('mousemove', handleUserInteraction, { passive: true, once: true }); - - // Eventos táctiles - cargar en primer toque (móviles) - window.addEventListener('touchstart', handleUserInteraction, { passive: true, once: true }); - - // Eventos de click - cargar en primer click - window.addEventListener('click', handleUserInteraction, { passive: true, once: true }); - - // Eventos de teclado - cargar en primera pulsación de tecla - window.addEventListener('keydown', handleUserInteraction, { passive: true, once: true }); + function removeLegacyEventListeners() { + window.removeEventListener('scroll', handleLegacyInteraction, { passive: true }); + window.removeEventListener('mousemove', handleLegacyInteraction, { passive: true }); + window.removeEventListener('touchstart', handleLegacyInteraction, { passive: true }); + window.removeEventListener('click', handleLegacyInteraction, { passive: true }); + window.removeEventListener('keydown', handleLegacyInteraction, { passive: true }); } /** - * Establece timeout de fallback para cargar AdSense después del tiempo especificado + * Inicia modo legacy con listeners de interaccion */ - function setTimeoutFallback() { - debugLog('Estableciendo timeout de fallback (' + CONFIG.timeout + 'ms)'); + function initLegacyMode() { + debugLog('Iniciando modo legacy'); + addLegacyEventListeners(); - loadTimeout = setTimeout(function() { - debugLog('Timeout alcanzado, cargando AdSense'); - loadAdSense(); - }, CONFIG.timeout); + legacyTimeout = setTimeout(function() { + debugLog('Timeout legacy alcanzado'); + loadAllAdsLegacy(); + }, CONFIG.fillTimeout); } + // ========================================================================= + // EVENTO DINAMICO + // ========================================================================= + /** - * Activa slots de AdSense insertados dinamicamente - * Escucha el evento 'roi-adsense-activate' disparado por otros scripts + * Configura listener para ads dinamicos */ function setupDynamicAdsListener() { window.addEventListener('roi-adsense-activate', function() { debugLog('Evento roi-adsense-activate recibido'); - // Si AdSense aun no ha cargado, forzar carga ahora - if (!adsenseLoaded) { - debugLog('AdSense no cargado, forzando carga...'); - loadAdSense(); - return; + if (CONFIG.lazyEnabled && slotObserver) { + observeNewSlots(); + } else if (!legacyLoaded) { + loadAllAdsLegacy(); + } else { + // Ya cargado en legacy, ejecutar nuevos push + activateDynamicSlotsLegacy(); } - - // AdSense ya cargado - activar nuevos slots - debugLog('Activando nuevos slots dinamicos...'); - activateDynamicSlots(); }); } /** - * Activa slots de AdSense que fueron insertados despues de la carga inicial + * Activa slots dinamicos en modo legacy */ - function activateDynamicSlots() { - // Buscar scripts de push que aun no han sido ejecutados + function activateDynamicSlotsLegacy() { var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]'); - if (pendingPushScripts.length === 0) { - debugLog('No hay slots pendientes por activar'); return; } - debugLog('Activando ' + pendingPushScripts.length + ' slot(s) dinamico(s)'); - - // Asegurar que adsbygoogle existe + debugLog('Activando ' + pendingPushScripts.length + ' slots dinamicos (legacy)'); window.adsbygoogle = window.adsbygoogle || []; pendingPushScripts.forEach(function(oldScript) { try { - // Crear nuevo script ejecutable var newScript = document.createElement('script'); newScript.type = 'text/javascript'; newScript.innerHTML = oldScript.innerHTML; - - // Reemplazar el placeholder con el script real oldScript.parentNode.replaceChild(newScript, oldScript); } catch (e) { - debugLog('Error activando slot: ' + e.message); + debugLog('Error activando slot dinamico: ' + e.message, 'error'); } }); } + // ========================================================================= + // INICIALIZACION + // ========================================================================= + /** - * Inicializa el cargador retrasado de AdSense + * Inicializa el sistema */ function init() { - // ========================================================================= - // NUEVO: Siempre configurar listener para ads dinamicos - // IMPORTANTE: Esto debe ejecutarse ANTES del early return - // porque los ads dinamicos pueden necesitar activarse aunque - // el delay global este deshabilitado - // ========================================================================= + // Siempre configurar listener para ads dinamicos setupDynamicAdsListener(); - debugLog('Listener para ads dinamicos configurado'); + debugLog('Listener dinamico configurado'); - // Verificar si el retardo de AdSense está habilitado + // Verificar si delay esta habilitado globalmente if (!window.roiAdsenseDelayed) { - debugLog('Retardo de AdSense no habilitado'); + debugLog('Delay global no habilitado'); return; } - debugLog('Inicializando cargador retrasado de AdSense'); + debugLog('Inicializando AdSense Lazy Loader v2.0'); + debugLog('Config: lazyEnabled=' + CONFIG.lazyEnabled + ', rootMargin=' + CONFIG.rootMargin + ', fillTimeout=' + CONFIG.fillTimeout); - // Verificar si la página ya está interactiva o completa + // Decidir modo de operacion + if (!CONFIG.lazyEnabled) { + debugLog('Lazy loading deshabilitado, usando modo legacy'); + initLegacyMode(); + return; + } + + // Intentar inicializar Intersection Observer + var observerInitialized = initIntersectionObserver(); + + if (!observerInitialized) { + // Fallback a modo legacy + initLegacyMode(); + return; + } + + // Esperar a que el DOM este listo if (document.readyState === 'interactive' || document.readyState === 'complete') { - debugLog('Página ya cargada, iniciando listeners'); - addEventListeners(); - setTimeoutFallback(); + observeAllSlots(); } else { - // Esperar a que el DOM esté listo - debugLog('Esperando a DOMContentLoaded'); document.addEventListener('DOMContentLoaded', function() { - debugLog('DOMContentLoaded disparado'); - addEventListeners(); - setTimeoutFallback(); + observeAllSlots(); }); } } - // Iniciar inicialización + // Iniciar init(); })(); diff --git a/Assets/Js/adsense-loader.legacy.js b/Assets/Js/adsense-loader.legacy.js new file mode 100644 index 00000000..55daf56b --- /dev/null +++ b/Assets/Js/adsense-loader.legacy.js @@ -0,0 +1,306 @@ +/** + * Cargador Retrasado de AdSense + * + * Este script retrasa la carga de Google AdSense hasta que haya interacción + * del usuario o se cumpla un timeout, mejorando el rendimiento de carga inicial. + * + * @package ROI_Theme + * @since 1.0.0 + */ + +(function() { + 'use strict'; + + // Configuración + const CONFIG = { + timeout: 5000, // Timeout de fallback en milisegundos + loadedClass: 'adsense-loaded', + debug: true // TEMPORAL: Habilitado para diagnóstico + }; + + // Estado + let adsenseLoaded = false; + let loadTimeout = null; + + /** + * Registra mensajes de debug si el modo debug está habilitado + * @param {string} message - El mensaje a registrar + */ + function debugLog(message) { + if (CONFIG.debug && typeof console !== 'undefined') { + console.log('[AdSense Loader] ' + message); + } + } + + /** + * Carga los scripts de AdSense e inicializa los ads + */ + function loadAdSense() { + // Prevenir múltiples cargas + if (adsenseLoaded) { + debugLog('AdSense ya fue cargado, omitiendo...'); + return; + } + + adsenseLoaded = true; + debugLog('Cargando scripts de AdSense...'); + + // Limpiar el timeout si existe + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = null; + } + + // Remover event listeners para prevenir múltiples triggers + removeEventListeners(); + + // Cargar etiquetas de script de AdSense y esperar a que cargue + // IMPORTANTE: Debe esperar a que adsbygoogle.js cargue antes de ejecutar push + loadAdSenseScripts(function() { + debugLog('Biblioteca AdSense cargada, ejecutando push scripts...'); + + // Ejecutar scripts de push de AdSense + executeAdSensePushScripts(); + + // Agregar clase loaded al body + document.body.classList.add(CONFIG.loadedClass); + + debugLog('Carga de AdSense completada'); + }); + } + + /** + * Encuentra y carga todas las etiquetas de script de AdSense retrasadas + * @param {Function} callback - Función a ejecutar cuando la biblioteca cargue + */ + function loadAdSenseScripts(callback) { + const delayedScripts = document.querySelectorAll('script[data-adsense-script]'); + + if (delayedScripts.length === 0) { + debugLog('No se encontraron scripts retrasados de AdSense'); + // Ejecutar callback de todas formas (puede haber ads sin script principal) + if (typeof callback === 'function') { + callback(); + } + return; + } + + debugLog('Se encontraron ' + delayedScripts.length + ' script(s) retrasado(s) de AdSense'); + + var scriptsLoaded = 0; + var totalScripts = delayedScripts.length; + + delayedScripts.forEach(function(oldScript) { + const newScript = document.createElement('script'); + + // Copiar atributos + if (oldScript.src) { + newScript.src = oldScript.src; + } + + // Establecer atributo async + newScript.async = true; + + // Copiar crossorigin si está presente + if (oldScript.getAttribute('crossorigin')) { + newScript.crossorigin = oldScript.getAttribute('crossorigin'); + } + + // Esperar a que cargue antes de ejecutar callback + newScript.onload = function() { + scriptsLoaded++; + debugLog('Script cargado (' + scriptsLoaded + '/' + totalScripts + '): ' + newScript.src.substring(0, 50) + '...'); + if (scriptsLoaded === totalScripts && typeof callback === 'function') { + callback(); + } + }; + + newScript.onerror = function() { + scriptsLoaded++; + debugLog('Error cargando script: ' + newScript.src); + if (scriptsLoaded === totalScripts && typeof callback === 'function') { + callback(); + } + }; + + // Reemplazar script viejo con el nuevo + oldScript.parentNode.replaceChild(newScript, oldScript); + }); + } + + /** + * Ejecuta scripts de push de AdSense retrasados + */ + function executeAdSensePushScripts() { + const delayedPushScripts = document.querySelectorAll('script[data-adsense-push]'); + + if (delayedPushScripts.length === 0) { + debugLog('No se encontraron scripts de push retrasados de AdSense'); + return; + } + + debugLog('Se encontraron ' + delayedPushScripts.length + ' script(s) de push retrasado(s)'); + + // Inicializar array adsbygoogle si no existe + window.adsbygoogle = window.adsbygoogle || []; + + delayedPushScripts.forEach(function(oldScript) { + const scriptContent = oldScript.innerHTML; + + // Crear y ejecutar nuevo script + const newScript = document.createElement('script'); + newScript.innerHTML = scriptContent; + newScript.type = 'text/javascript'; + + // Reemplazar script viejo con el nuevo + oldScript.parentNode.replaceChild(newScript, oldScript); + }); + } + + /** + * Manejador de eventos para interacciones del usuario + */ + function handleUserInteraction() { + debugLog('Interacción del usuario detectada'); + loadAdSense(); + } + + /** + * Remueve todos los event listeners + */ + function removeEventListeners() { + window.removeEventListener('scroll', handleUserInteraction, { passive: true }); + window.removeEventListener('mousemove', handleUserInteraction, { passive: true }); + window.removeEventListener('touchstart', handleUserInteraction, { passive: true }); + window.removeEventListener('click', handleUserInteraction, { passive: true }); + window.removeEventListener('keydown', handleUserInteraction, { passive: true }); + } + + /** + * Agrega event listeners para interacciones del usuario + */ + function addEventListeners() { + debugLog('Agregando event listeners para interacción del usuario'); + + // Evento de scroll - cargar en primer scroll + window.addEventListener('scroll', handleUserInteraction, { passive: true, once: true }); + + // Movimiento de mouse - cargar cuando el usuario mueve el mouse + window.addEventListener('mousemove', handleUserInteraction, { passive: true, once: true }); + + // Eventos táctiles - cargar en primer toque (móviles) + window.addEventListener('touchstart', handleUserInteraction, { passive: true, once: true }); + + // Eventos de click - cargar en primer click + window.addEventListener('click', handleUserInteraction, { passive: true, once: true }); + + // Eventos de teclado - cargar en primera pulsación de tecla + window.addEventListener('keydown', handleUserInteraction, { passive: true, once: true }); + } + + /** + * Establece timeout de fallback para cargar AdSense después del tiempo especificado + */ + function setTimeoutFallback() { + debugLog('Estableciendo timeout de fallback (' + CONFIG.timeout + 'ms)'); + + loadTimeout = setTimeout(function() { + debugLog('Timeout alcanzado, cargando AdSense'); + loadAdSense(); + }, CONFIG.timeout); + } + + /** + * Activa slots de AdSense insertados dinamicamente + * Escucha el evento 'roi-adsense-activate' disparado por otros scripts + */ + function setupDynamicAdsListener() { + window.addEventListener('roi-adsense-activate', function() { + debugLog('Evento roi-adsense-activate recibido'); + + // Si AdSense aun no ha cargado, forzar carga ahora + if (!adsenseLoaded) { + debugLog('AdSense no cargado, forzando carga...'); + loadAdSense(); + return; + } + + // AdSense ya cargado - activar nuevos slots + debugLog('Activando nuevos slots dinamicos...'); + activateDynamicSlots(); + }); + } + + /** + * Activa slots de AdSense que fueron insertados despues de la carga inicial + */ + function activateDynamicSlots() { + // Buscar scripts de push que aun no han sido ejecutados + var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]'); + + if (pendingPushScripts.length === 0) { + debugLog('No hay slots pendientes por activar'); + return; + } + + debugLog('Activando ' + pendingPushScripts.length + ' slot(s) dinamico(s)'); + + // Asegurar que adsbygoogle existe + window.adsbygoogle = window.adsbygoogle || []; + + pendingPushScripts.forEach(function(oldScript) { + try { + // Crear nuevo script ejecutable + var newScript = document.createElement('script'); + newScript.type = 'text/javascript'; + newScript.innerHTML = oldScript.innerHTML; + + // Reemplazar el placeholder con el script real + oldScript.parentNode.replaceChild(newScript, oldScript); + } catch (e) { + debugLog('Error activando slot: ' + e.message); + } + }); + } + + /** + * Inicializa el cargador retrasado de AdSense + */ + function init() { + // ========================================================================= + // NUEVO: Siempre configurar listener para ads dinamicos + // IMPORTANTE: Esto debe ejecutarse ANTES del early return + // porque los ads dinamicos pueden necesitar activarse aunque + // el delay global este deshabilitado + // ========================================================================= + setupDynamicAdsListener(); + debugLog('Listener para ads dinamicos configurado'); + + // Verificar si el retardo de AdSense está habilitado + if (!window.roiAdsenseDelayed) { + debugLog('Retardo de AdSense no habilitado'); + return; + } + + debugLog('Inicializando cargador retrasado de AdSense'); + + // Verificar si la página ya está interactiva o completa + if (document.readyState === 'interactive' || document.readyState === 'complete') { + debugLog('Página ya cargada, iniciando listeners'); + addEventListeners(); + setTimeoutFallback(); + } else { + // Esperar a que el DOM esté listo + debugLog('Esperando a DOMContentLoaded'); + document.addEventListener('DOMContentLoaded', function() { + debugLog('DOMContentLoaded disparado'); + addEventListeners(); + setTimeoutFallback(); + }); + } + } + + // Iniciar inicialización + init(); + +})(); diff --git a/Inc/enqueue-scripts.php b/Inc/enqueue-scripts.php index 03207fef..e2a98804 100644 --- a/Inc/enqueue-scripts.php +++ b/Inc/enqueue-scripts.php @@ -489,6 +489,18 @@ function roi_enqueue_adsense_loader() { 'strategy' => 'defer', ) ); + + // Pasar configuración de lazy loading a JavaScript + $lazy_enabled = roi_get_component_setting('adsense-placement', 'behavior', 'lazy_loading_enabled', true); + $lazy_rootmargin = roi_get_component_setting('adsense-placement', 'behavior', 'lazy_rootmargin', '200'); + $lazy_fill_timeout = roi_get_component_setting('adsense-placement', 'behavior', 'lazy_fill_timeout', '5000'); + + wp_localize_script('roi-adsense-loader', 'roiAdsenseConfig', array( + 'lazyEnabled' => (bool) $lazy_enabled, + 'rootMargin' => (int) $lazy_rootmargin . 'px 0px', + 'fillTimeout' => (int) $lazy_fill_timeout, + 'debug' => defined('WP_DEBUG') && WP_DEBUG, + )); } add_action('wp_enqueue_scripts', 'roi_enqueue_adsense_loader', 10); diff --git a/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php b/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php index c7b59a3e..fbaa6028 100644 --- a/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php +++ b/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php @@ -64,10 +64,12 @@ final class AdsensePlacementRenderer } // 4. Generar CSS (usando CSSGeneratorService) + $lazyEnabled = ($settings['behavior']['lazy_loading_enabled'] ?? true) === true; + $css = $this->cssGenerator->generate( ".roi-ad-slot", [ - 'display' => 'block', + 'display' => $lazyEnabled ? 'none' : 'block', 'width' => '100%', 'min_width' => '300px', 'margin_top' => '1.5rem', @@ -76,6 +78,12 @@ final class AdsensePlacementRenderer ] ); + // CSS para slots con lazy loading que reciben contenido + if ($lazyEnabled) { + $css .= $this->cssGenerator->generate('.roi-ad-slot.roi-ad-filled', ['display' => 'block']); + $css .= $this->cssGenerator->generate('.roi-ad-slot.roi-ad-empty', ['display' => 'none']); + } + // 5. Generar HTML del anuncio $html = $this->buildAdHTML( $settings, @@ -161,6 +169,7 @@ final class AdsensePlacementRenderer { $publisherId = esc_attr($settings['content']['publisher_id'] ?? ''); $delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true; + $lazyEnabled = ($settings['behavior']['lazy_loading_enabled'] ?? true) === true; if (empty($publisherId)) { return ''; @@ -174,9 +183,10 @@ final class AdsensePlacementRenderer $scriptType = $delayEnabled ? 'text/plain' : 'text/javascript'; $dataAttr = $delayEnabled ? ' data-adsense-push' : ''; + $lazyAttr = $lazyEnabled ? ' data-ad-lazy="true"' : ''; $locationClass = 'roi-ad-' . esc_attr(str_replace('_', '-', $location)); - return $this->generateAdMarkup($format, $publisherId, $slotId, $locationClass, $visClasses, $scriptType, $dataAttr); + return $this->generateAdMarkup($format, $publisherId, $slotId, $locationClass, $visClasses, $scriptType, $dataAttr, $lazyAttr); } /** @@ -217,68 +227,69 @@ final class AdsensePlacementRenderer string $locationClass, string $visClasses, string $scriptType, - string $dataAttr + string $dataAttr, + string $lazyAttr = '' ): string { $allClasses = trim("{$locationClass} {$visClasses}"); return match($format) { - 'display' => $this->adDisplay($client, $slot, 728, 90, $allClasses, $scriptType, $dataAttr), - 'display-large' => $this->adDisplay($client, $slot, 970, 250, $allClasses, $scriptType, $dataAttr), - 'display-square' => $this->adDisplay($client, $slot, 300, 250, $allClasses, $scriptType, $dataAttr), - 'in-article' => $this->adInArticle($client, $slot, $allClasses, $scriptType, $dataAttr), - 'autorelaxed' => $this->adAutorelaxed($client, $slot, $allClasses, $scriptType, $dataAttr), - default => $this->adAuto($client, $slot, $allClasses, $scriptType, $dataAttr), + 'display' => $this->adDisplay($client, $slot, 728, 90, $allClasses, $scriptType, $dataAttr, $lazyAttr), + 'display-large' => $this->adDisplay($client, $slot, 970, 250, $allClasses, $scriptType, $dataAttr, $lazyAttr), + 'display-square' => $this->adDisplay($client, $slot, 300, 250, $allClasses, $scriptType, $dataAttr, $lazyAttr), + 'in-article' => $this->adInArticle($client, $slot, $allClasses, $scriptType, $dataAttr, $lazyAttr), + 'autorelaxed' => $this->adAutorelaxed($client, $slot, $allClasses, $scriptType, $dataAttr, $lazyAttr), + default => $this->adAuto($client, $slot, $allClasses, $scriptType, $dataAttr, $lazyAttr), }; } - private function adDisplay(string $c, string $s, int $w, int $h, string $cl, string $t, string $a): string + private function adDisplay(string $c, string $s, int $w, int $h, string $cl, string $t, string $a, string $lazy = ''): string { return sprintf( - '
+ '
', - esc_attr($cl), $w, $h, esc_attr($c), esc_attr($s), $t, $a + esc_attr($cl), $lazy, $w, $h, esc_attr($c), esc_attr($s), $t, $a ); } - private function adAuto(string $c, string $s, string $cl, string $t, string $a): string + private function adAuto(string $c, string $s, string $cl, string $t, string $a, string $lazy = ''): string { return sprintf( - '
+ '
', - esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a + esc_attr($cl), $lazy, esc_attr($c), esc_attr($s), $t, $a ); } - private function adInArticle(string $c, string $s, string $cl, string $t, string $a): string + private function adInArticle(string $c, string $s, string $cl, string $t, string $a, string $lazy = ''): string { return sprintf( - '
+ '
', - esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a + esc_attr($cl), $lazy, esc_attr($c), esc_attr($s), $t, $a ); } - private function adAutorelaxed(string $c, string $s, string $cl, string $t, string $a): string + private function adAutorelaxed(string $c, string $s, string $cl, string $t, string $a, string $lazy = ''): string { return sprintf( - '
+ '
', - esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a + esc_attr($cl), $lazy, esc_attr($c), esc_attr($s), $t, $a ); } diff --git a/Schemas/adsense-placement.json b/Schemas/adsense-placement.json index 9a8bc781..e682c4b7 100644 --- a/Schemas/adsense-placement.json +++ b/Schemas/adsense-placement.json @@ -1,6 +1,6 @@ { "component_name": "adsense-placement", - "version": "1.4.0", + "version": "1.5.0", "description": "Control de AdSense y Google Analytics - Con In-Content Ads Avanzado", "groups": { "visibility": { @@ -423,6 +423,41 @@ "700": "700px (Debajo del fold)" }, "description": "Distancia vertical desde el top del viewport" + }, + "lazy_loading_enabled": { + "type": "boolean", + "label": "Lazy Loading de Anuncios", + "default": true, + "editable": true, + "description": "Cargar anuncios individualmente al entrar al viewport (mejora fill rate)" + }, + "lazy_rootmargin": { + "type": "select", + "label": "Pre-carga (px antes del viewport)", + "default": "200", + "editable": true, + "options": { + "0": "0px (sin pre-carga)", + "100": "100px", + "200": "200px (recomendado)", + "300": "300px", + "400": "400px", + "500": "500px" + }, + "description": "Pixeles de anticipacion para iniciar carga de anuncio" + }, + "lazy_fill_timeout": { + "type": "select", + "label": "Timeout de llenado (ms)", + "default": "5000", + "editable": true, + "options": { + "3000": "3 segundos", + "5000": "5 segundos (recomendado)", + "7000": "7 segundos", + "10000": "10 segundos" + }, + "description": "Tiempo maximo para esperar contenido de Google antes de ocultar slot" } } }, diff --git a/openspec/changes/refactor-adsense-lazy-loading/design.md b/openspec/changes/refactor-adsense-lazy-loading/design.md new file mode 100644 index 00000000..a2d83893 --- /dev/null +++ b/openspec/changes/refactor-adsense-lazy-loading/design.md @@ -0,0 +1,274 @@ +# Design: AdSense Lazy Loading con Intersection Observer + +## Context + +### Problema Actual + +El `adsense-loader.js` actual implementa un modelo "todo o nada": + +1. Usuario interactua (scroll/click) O timeout 5s +2. Se carga `adsbygoogle.js` (biblioteca principal) +3. Se ejecutan TODOS los `push({})` simultaneamente +4. Google intenta llenar TODOS los slots de una vez + +**Consecuencias:** +- Fill rate bajo: Google tiene limite de ads por pagina/sesion +- Slots vacios visibles: No hay inventario para todos +- Impresiones desperdiciadas: Ads below-the-fold nunca vistos +- Impacto en Core Web Vitals: Carga masiva de recursos + +### Solucion Propuesta + +Cambiar a modelo "por demanda con visibilidad": + +1. La biblioteca `adsbygoogle.js` se carga UNA vez (primer ad visible) +2. Cada slot individual se activa al entrar en viewport +3. Slots permanecen ocultos hasta que tengan contenido +4. No hay timeout global, cada ad tiene su propio trigger + +## Goals / Non-Goals + +### Goals + +- Mejorar fill rate cargando ads secuencialmente +- Eliminar espacios en blanco de slots vacios +- Reducir tiempo de carga inicial (menos JS ejecutado) +- Mejorar Core Web Vitals (menor TBT, mejor LCP) +- Cumplir politicas de Google AdSense + +### Non-Goals + +- Reciclar o eliminar ads ya cargados (viola politicas) +- Implementar "infinite scroll" de ads +- Cache de contenido de ads +- Prefetch de ads futuros + +## Decisions + +### Decision 1: Extension del Modulo Existente AdsensePlacement + +**Razon:** Mantener Clean Architecture del proyecto. No crear modulo nuevo. + +**Ubicacion de archivos:** +- Schema: `Schemas/adsense-placement.json` (nuevos campos en grupo `forms`) +- Renderer: `Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php` +- FormBuilder: `Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php` +- FieldMapper: `Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php` +- Asset Enqueuer: `Public/AdsensePlacement/Infrastructure/Services/AdsenseAssetEnqueuer.php` +- JavaScript: `Assets/Js/adsense-loader.js` + +**Alternativas descartadas:** +- Crear modulo nuevo `AdsenseLazyLoading`: Viola principio de cohesion, duplica logica + +### Decision 2: Usar Intersection Observer API + +**Razon:** API nativa del navegador, alto rendimiento, soporte >95% global. + +**Alternativas consideradas:** +- Scroll listener + getBoundingClientRect(): Mayor consumo de CPU +- requestAnimationFrame loop: Complejo, mismo resultado +- Third-party library (lozad.js): Dependencia innecesaria + +### Decision 3: Ocultar slots por defecto con CSS Dinamico + +**Razon:** Evitar layout shift (CLS) cuando un slot no recibe ad. + +**Implementacion via CSSGeneratorService** (NO CSS estatico): +```php +// En AdsensePlacementRenderer.php +$this->cssGenerator->generate([ + '.roi-ad-slot' => [ + 'display' => $lazyEnabled ? 'none' : 'block', + ], + '.roi-ad-slot.roi-ad-filled' => [ + 'display' => 'block', + ], +]); +``` + +**Alternativas descartadas:** +- CSS estatico en archivo: Viola arquitectura del tema +- `visibility: hidden`: Ocupa espacio, causa CLS +- `height: 0; overflow: hidden`: Hack, problemas con responsive +- Remover del DOM: Viola politicas de AdSense + +### Decision 4: Criterios Concretos de Fill Detection + +**Razon:** Evitar ambiguedad sobre cuando un ad "tiene contenido". + +**Criterios para marcar como `roi-ad-filled`:** +1. El elemento `` contiene al menos un hijo +2. **Y** ese hijo es un `