feat(adsense): implementar Anchor Ads y Vignette Ads

- Anchor Ads: anuncios fijos top/bottom con botones minimizar/cerrar
- Vignette Ads: modal fullscreen con triggers configurables
- Schema v1.3.0 con grupos anchor_ads y vignette_ads (18 campos)
- FieldMapper actualizado para persistir settings en BD
- JavaScript para interacción (colapso, cierre, localStorage)
- Soporte para responsive y tamaños fijos en vignette

IMPORTANTE: Ejecutar en servidor remoto:
wp roi-theme sync-component adsense-placement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-28 21:00:00 -06:00
parent 4d5cc1a58c
commit b96a13427e
6 changed files with 1313 additions and 5 deletions

View File

@@ -549,4 +549,395 @@ final class AdsensePlacementRenderer
return $html;
}
/**
* Renderiza Anchor Ads (anuncios fijos en top/bottom)
*
* @param array $settings Configuracion desde BD
* @return string HTML de los anchor ads
*/
public function renderAnchorAds(array $settings): string
{
// Verificar si Anchor Ads estan habilitados
if (!($settings['visibility']['is_enabled'] ?? false)) {
return '';
}
if (!($settings['anchor_ads']['anchor_enabled'] ?? false)) {
return '';
}
$publisherId = esc_attr($settings['content']['publisher_id'] ?? '');
$slotId = $settings['content']['slot_auto'] ?? '';
if (empty($publisherId) || empty($slotId)) {
return '';
}
// Configuracion de anchor
$anchorConfig = $settings['anchor_ads'] ?? [];
$position = $anchorConfig['anchor_position'] ?? 'bottom';
$height = (int)($anchorConfig['anchor_height'] ?? 90);
$showMobile = ($anchorConfig['anchor_show_on_mobile'] ?? true) === true;
$showWide = ($anchorConfig['anchor_show_on_wide_screens'] ?? false) === true;
$collapsible = ($anchorConfig['anchor_collapsible_enabled'] ?? true) === true;
$collapsedHeight = (int)($anchorConfig['anchor_collapsed_height'] ?? 24);
$collapseText = esc_html($anchorConfig['anchor_collapse_button_text'] ?? 'Ver anuncio');
$closePosition = $anchorConfig['anchor_close_position'] ?? 'right';
$rememberState = ($anchorConfig['anchor_remember_state'] ?? true) === true;
$rememberDuration = $anchorConfig['anchor_remember_duration'] ?? 'session';
$delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true;
$scriptType = $delayEnabled ? 'text/plain' : 'text/javascript';
$dataAttr = $delayEnabled ? ' data-adsense-push' : '';
// === CSS via CSSGenerator ===
$cssRules = [];
// Base anchor styles
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad', [
'position' => 'fixed',
'left' => '0',
'right' => '0',
'z-index' => '9999',
'background' => '#f8f9fa',
'border-color' => '#dee2e6',
'box-shadow' => '0 -2px 10px rgba(0,0,0,0.1)',
'transition' => 'height 0.3s ease, transform 0.3s ease, opacity 0.3s ease',
'display' => 'flex',
'align-items' => 'center',
'justify-content' => 'center',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad-top', [
'top' => '0',
'border-bottom-width' => '1px',
'border-bottom-style' => 'solid',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad-bottom', [
'bottom' => '0',
'border-top-width' => '1px',
'border-top-style' => 'solid',
]);
// Controls container
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-controls', [
'position' => 'absolute',
'top' => '4px',
'display' => 'flex',
'gap' => '4px',
'z-index' => '10',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-controls-left', ['left' => '8px']);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-controls-right', ['right' => '8px']);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-controls-center', ['left' => '50%', 'transform' => 'translateX(-50%)']);
// Buttons
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-btn', [
'width' => '28px',
'height' => '28px',
'border' => 'none',
'border-radius' => '4px',
'background' => 'rgba(0,0,0,0.1)',
'color' => '#333',
'cursor' => 'pointer',
'font-size' => '14px',
'display' => 'flex',
'align-items' => 'center',
'justify-content' => 'center',
'transition' => 'background 0.2s',
]);
// Collapsed state
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad.collapsed', [
'height' => $collapsedHeight . 'px !important',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad.collapsed .roi-anchor-content', [
'display' => 'none',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-expand-bar', [
'display' => 'none',
'width' => '100%',
'height' => '100%',
'background' => '#e9ecef',
'border' => 'none',
'cursor' => 'pointer',
'font-size' => '12px',
'color' => '#495057',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad.collapsed .roi-anchor-expand-bar', [
'display' => 'flex',
'align-items' => 'center',
'justify-content' => 'center',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-anchor-ad.hidden', [
'display' => 'none !important',
]);
// Media query para visibilidad
if (!$showMobile && $showWide) {
$cssRules[] = "@media (max-width: 999px) { .roi-anchor-ad { display: none !important; } }";
} elseif ($showMobile && !$showWide) {
$cssRules[] = "@media (min-width: 1000px) { .roi-anchor-ad { display: none !important; } }";
} elseif (!$showMobile && !$showWide) {
$cssRules[] = ".roi-anchor-ad { display: none !important; }";
}
$css = implode("\n", $cssRules);
$html = "<style id=\"roi-anchor-ads-css\">{$css}</style>\n";
// Renderizar anchor(s)
$controlsClass = 'roi-anchor-controls roi-anchor-controls-' . esc_attr($closePosition);
if ($position === 'top' || $position === 'both') {
$html .= $this->buildAnchorHTML('top', $height, $publisherId, $slotId, $scriptType, $dataAttr, $controlsClass, $collapsible, $collapseText);
}
if ($position === 'bottom' || $position === 'both') {
$html .= $this->buildAnchorHTML('bottom', $height, $publisherId, $slotId, $scriptType, $dataAttr, $controlsClass, $collapsible, $collapseText);
}
// Config para JavaScript (data attributes en lugar de inline JS)
$jsConfig = json_encode([
'rememberState' => $rememberState,
'rememberDuration' => $rememberDuration,
'collapsible' => $collapsible,
]);
$html .= '<script id="roi-anchor-config" type="application/json">' . $jsConfig . '</script>';
return $html;
}
/**
* Genera HTML para un anchor individual
*/
private function buildAnchorHTML(
string $pos,
int $height,
string $client,
string $slot,
string $scriptType,
string $dataAttr,
string $controlsClass,
bool $collapsible,
string $collapseText
): string {
$posClass = 'roi-anchor-ad-' . $pos;
$collapseBtn = $collapsible
? '<button class="roi-anchor-btn" data-action="collapse" title="Minimizar"><i class="bi bi-dash"></i></button>'
: '';
return sprintf(
'<div class="roi-anchor-ad %s" data-position="%s" style="height:%dpx;">
<div class="%s">
%s
<button class="roi-anchor-btn" data-action="close" title="Cerrar"><i class="bi bi-x"></i></button>
</div>
<div class="roi-anchor-content">
<ins class="adsbygoogle" style="display:block;width:100%%;height:%dpx"
data-ad-client="%s" data-ad-slot="%s"
data-ad-format="auto" data-full-width-responsive="true"></ins>
<script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<button class="roi-anchor-expand-bar" data-action="expand">
<i class="bi bi-plus-circle me-1"></i> %s
</button>
</div>',
esc_attr($posClass),
esc_attr($pos),
$height,
esc_attr($controlsClass),
$collapseBtn,
$height - 10,
esc_attr($client),
esc_attr($slot),
$scriptType,
$dataAttr,
esc_html($collapseText)
);
}
/**
* Renderiza Vignette Ad (pantalla completa)
*
* @param array $settings Configuracion desde BD
* @return string HTML del vignette ad
*/
public function renderVignetteAd(array $settings): string
{
// Verificar si Vignette Ads estan habilitados
if (!($settings['visibility']['is_enabled'] ?? false)) {
return '';
}
if (!($settings['vignette_ads']['vignette_enabled'] ?? false)) {
return '';
}
$publisherId = esc_attr($settings['content']['publisher_id'] ?? '');
$slotId = $settings['content']['slot_display'] ?? $settings['content']['slot_auto'] ?? '';
if (empty($publisherId) || empty($slotId)) {
return '';
}
// Configuracion de vignette
$vignetteConfig = $settings['vignette_ads'] ?? [];
$trigger = $vignetteConfig['vignette_trigger'] ?? 'pageview';
$triggerDelay = (int)($vignetteConfig['vignette_trigger_delay'] ?? 5);
$showMobile = ($vignetteConfig['vignette_show_on_mobile'] ?? true) === true;
$showDesktop = ($vignetteConfig['vignette_show_on_desktop'] ?? true) === true;
$size = $vignetteConfig['vignette_size'] ?? '300x250';
$overlayOpacity = (float)($vignetteConfig['vignette_overlay_opacity'] ?? 0.7);
$closeDelay = (int)($vignetteConfig['vignette_close_button_delay'] ?? 0);
$reshowEnabled = ($vignetteConfig['vignette_reshow_enabled'] ?? true) === true;
$reshowTime = (int)($vignetteConfig['vignette_reshow_time'] ?? 5);
$maxPerSession = $vignetteConfig['vignette_max_per_session'] ?? '3';
$maxPerPage = $vignetteConfig['vignette_max_per_page'] ?? '1';
// Calcular dimensiones
list($adWidth, $adHeight) = $this->parseVignetteSize($size);
$delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true;
$scriptType = $delayEnabled ? 'text/plain' : 'text/javascript';
$dataAttr = $delayEnabled ? ' data-adsense-push' : '';
// === CSS via CSSGenerator ===
$cssRules = [];
$cssRules[] = $this->cssGenerator->generate('.roi-vignette-overlay', [
'position' => 'fixed',
'top' => '0',
'left' => '0',
'right' => '0',
'bottom' => '0',
'background' => 'rgba(0,0,0,' . $overlayOpacity . ')',
'z-index' => '99999',
'display' => 'none',
'align-items' => 'center',
'justify-content' => 'center',
'opacity' => '0',
'transition' => 'opacity 0.3s ease',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-vignette-overlay.active', [
'display' => 'flex',
'opacity' => '1',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-vignette-modal', [
'position' => 'relative',
'background' => '#fff',
'border-radius' => '8px',
'padding' => '20px',
'box-shadow' => '0 10px 40px rgba(0,0,0,0.3)',
'max-width' => '95vw',
'max-height' => '95vh',
'overflow' => 'auto',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-vignette-close', [
'position' => 'absolute',
'top' => '-12px',
'right' => '-12px',
'width' => '32px',
'height' => '32px',
'border' => 'none',
'border-radius' => '50%',
'background' => '#dc3545',
'color' => '#fff',
'cursor' => 'pointer',
'font-size' => '18px',
'display' => 'flex',
'align-items' => 'center',
'justify-content' => 'center',
'box-shadow' => '0 2px 8px rgba(0,0,0,0.2)',
'transition' => 'transform 0.2s, opacity 0.3s',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-vignette-close.delayed', [
'opacity' => '0',
'pointer-events' => 'none',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-vignette-close:not(.delayed)', [
'opacity' => '1',
'pointer-events' => 'auto',
]);
// Media query para visibilidad
if (!$showMobile && $showDesktop) {
$cssRules[] = "@media (max-width: 991px) { .roi-vignette-overlay { display: none !important; } }";
} elseif ($showMobile && !$showDesktop) {
$cssRules[] = "@media (min-width: 992px) { .roi-vignette-overlay { display: none !important; } }";
} elseif (!$showMobile && !$showDesktop) {
$cssRules[] = ".roi-vignette-overlay { display: none !important; }";
}
$css = implode("\n", $cssRules);
$html = "<style id=\"roi-vignette-css\">{$css}</style>\n";
// Determinar estilo de anuncio segun tamano
$adStyle = $size === 'responsive'
? 'display:block;min-width:300px;min-height:250px'
: sprintf('display:inline-block;width:%dpx;height:%dpx', $adWidth, $adHeight);
$adFormat = $size === 'responsive' ? ' data-ad-format="auto" data-full-width-responsive="true"' : '';
$closeDelayClass = $closeDelay > 0 ? ' delayed' : '';
$html .= sprintf(
'<div id="roi-vignette-overlay" class="roi-vignette-overlay">
<div class="roi-vignette-modal">
<button class="roi-vignette-close%s" data-action="close-vignette" title="Cerrar" data-delay="%d">
<i class="bi bi-x"></i>
</button>
<ins class="adsbygoogle" style="%s"
data-ad-client="%s" data-ad-slot="%s"%s></ins>
<script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
</div>',
$closeDelayClass,
$closeDelay,
$adStyle,
esc_attr($publisherId),
esc_attr($slotId),
$adFormat,
$scriptType,
$dataAttr
);
// Config para JavaScript
$jsConfig = json_encode([
'trigger' => $trigger,
'triggerDelay' => $triggerDelay,
'closeDelay' => $closeDelay,
'reshowEnabled' => $reshowEnabled,
'reshowTime' => $reshowTime,
'maxPerSession' => $maxPerSession,
'maxPerPage' => $maxPerPage,
]);
$html .= '<script id="roi-vignette-config" type="application/json">' . $jsConfig . '</script>';
return $html;
}
/**
* Parsea el tamano del vignette
*/
private function parseVignetteSize(string $size): array
{
return match($size) {
'300x250' => [300, 250],
'336x280' => [336, 280],
default => [300, 250],
};
}
}

View File

@@ -0,0 +1,365 @@
/**
* ROI Theme - Anchor & Vignette Ads JavaScript
*
* Logica para:
* - Mostrar/ocultar/colapsar anchors
* - Triggers de vignette (pageview, scroll, exit_intent, time_delay)
* - Persistencia con localStorage
* - Tiempo de reaparicion configurable
*
* NO usa onclick inline - usa addEventListener
*/
(function() {
'use strict';
// =====================================================
// UTILIDADES
// =====================================================
/**
* Obtiene configuracion desde script type="application/json"
*/
function getConfig(id) {
var el = document.getElementById(id);
if (!el) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
return null;
}
}
/**
* Calcula duracion en milisegundos
*/
function getDurationMs(duration) {
switch (duration) {
case 'session': return 0; // sessionStorage
case '1hour': return 60 * 60 * 1000;
case '1day': return 24 * 60 * 60 * 1000;
case '1week': return 7 * 24 * 60 * 60 * 1000;
default: return 0;
}
}
/**
* Guarda estado en storage
*/
function saveState(key, value, duration) {
var data = {
value: value,
expires: duration === 'session' ? 0 : Date.now() + getDurationMs(duration)
};
if (duration === 'session') {
sessionStorage.setItem(key, JSON.stringify(data));
} else {
localStorage.setItem(key, JSON.stringify(data));
}
}
/**
* Recupera estado desde storage
*/
function getState(key, duration) {
var storage = duration === 'session' ? sessionStorage : localStorage;
var raw = storage.getItem(key);
if (!raw) return null;
try {
var data = JSON.parse(raw);
// Verificar expiracion
if (data.expires && data.expires < Date.now()) {
storage.removeItem(key);
return null;
}
return data.value;
} catch (e) {
return null;
}
}
// =====================================================
// ANCHOR ADS
// =====================================================
function initAnchorAds() {
var config = getConfig('roi-anchor-config');
if (!config) return;
var anchors = document.querySelectorAll('.roi-anchor-ad');
if (!anchors.length) return;
// Restaurar estados guardados
anchors.forEach(function(anchor) {
var pos = anchor.dataset.position;
var stateKey = 'roi_anchor_' + pos + '_state';
if (config.rememberState) {
var savedState = getState(stateKey, config.rememberDuration);
if (savedState === 'closed') {
anchor.classList.add('hidden');
} else if (savedState === 'collapsed' && config.collapsible) {
anchor.classList.add('collapsed');
}
}
});
// Event delegation para botones
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.dataset.action;
var anchor = btn.closest('.roi-anchor-ad');
if (!anchor) return;
var pos = anchor.dataset.position;
var stateKey = 'roi_anchor_' + pos + '_state';
switch (action) {
case 'close':
anchor.classList.add('hidden');
if (config.rememberState) {
saveState(stateKey, 'closed', config.rememberDuration);
}
break;
case 'collapse':
if (config.collapsible) {
anchor.classList.add('collapsed');
if (config.rememberState) {
saveState(stateKey, 'collapsed', config.rememberDuration);
}
}
break;
case 'expand':
anchor.classList.remove('collapsed');
if (config.rememberState) {
saveState(stateKey, 'expanded', config.rememberDuration);
}
break;
}
});
}
// =====================================================
// VIGNETTE ADS
// =====================================================
function initVignetteAds() {
var config = getConfig('roi-vignette-config');
if (!config) return;
var overlay = document.getElementById('roi-vignette-overlay');
if (!overlay) return;
var STORAGE_KEYS = {
lastClosed: 'roi_vignette_last_closed',
sessionCount: 'roi_vignette_session_count',
pageCount: 'roi_vignette_page_count_' + window.location.pathname
};
// Estado local
var pageShowCount = 0;
var triggered = false;
/**
* Verifica si se puede mostrar el vignette
*/
function canShow() {
// Verificar max por pagina
var maxPage = config.maxPerPage === 'unlimited' ? 999 : parseInt(config.maxPerPage);
if (pageShowCount >= maxPage) return false;
// Verificar max por sesion
var maxSession = config.maxPerSession === 'unlimited' ? 999 : parseInt(config.maxPerSession);
var sessionCount = parseInt(sessionStorage.getItem(STORAGE_KEYS.sessionCount) || '0');
if (sessionCount >= maxSession) return false;
// Verificar tiempo de reaparicion
if (config.reshowEnabled) {
var lastClosed = parseInt(localStorage.getItem(STORAGE_KEYS.lastClosed) || '0');
var minWait = config.reshowTime * 60 * 1000; // minutos a ms
if (lastClosed && (Date.now() - lastClosed) < minWait) {
return false;
}
} else {
// Si no se permite reaparicion, verificar si ya se cerro
if (sessionStorage.getItem(STORAGE_KEYS.lastClosed)) {
return false;
}
}
return true;
}
/**
* Muestra el vignette
*/
function showVignette() {
if (!canShow() || triggered) return;
triggered = true;
// Mostrar overlay
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
// Incrementar contadores
pageShowCount++;
var sessionCount = parseInt(sessionStorage.getItem(STORAGE_KEYS.sessionCount) || '0');
sessionStorage.setItem(STORAGE_KEYS.sessionCount, (sessionCount + 1).toString());
// Manejar delay del boton cerrar
var closeBtn = overlay.querySelector('.roi-vignette-close');
if (closeBtn && closeBtn.classList.contains('delayed')) {
var delay = parseInt(closeBtn.dataset.delay || '0') * 1000;
setTimeout(function() {
closeBtn.classList.remove('delayed');
}, delay);
}
}
/**
* Cierra el vignette
*/
function closeVignette() {
overlay.classList.remove('active');
document.body.style.overflow = '';
// Guardar tiempo de cierre
localStorage.setItem(STORAGE_KEYS.lastClosed, Date.now().toString());
if (!config.reshowEnabled) {
sessionStorage.setItem(STORAGE_KEYS.lastClosed, '1');
}
// Permitir nueva trigger si se permite reaparicion
if (config.reshowEnabled) {
triggered = false;
}
}
// Event listeners para cerrar
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="close-vignette"]');
if (btn) {
closeVignette();
return;
}
// Click fuera del modal
if (e.target === overlay) {
closeVignette();
}
});
// Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && overlay.classList.contains('active')) {
closeVignette();
}
});
// === TRIGGERS ===
/**
* Trigger: Al cargar pagina
*/
function setupPageviewTrigger() {
setTimeout(function() {
showVignette();
}, config.triggerDelay * 1000);
}
/**
* Trigger: Al scrollear X%
*/
function setupScrollTrigger(percent) {
var scrollHandler = function() {
if (triggered) return;
var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
var scrolled = window.scrollY / scrollHeight;
if (scrolled >= percent / 100) {
showVignette();
window.removeEventListener('scroll', scrollHandler);
}
};
window.addEventListener('scroll', scrollHandler, { passive: true });
}
/**
* Trigger: Exit intent (mouse sale del viewport)
*/
function setupExitIntentTrigger() {
var exitHandler = function(e) {
if (triggered) return;
// Solo activar si el mouse sale por arriba
if (e.clientY <= 0) {
showVignette();
document.removeEventListener('mouseout', exitHandler);
}
};
document.addEventListener('mouseout', exitHandler);
}
/**
* Trigger: Despues de X segundos
*/
function setupTimeDelayTrigger() {
setTimeout(function() {
showVignette();
}, config.triggerDelay * 1000);
}
// Configurar trigger segun config
switch (config.trigger) {
case 'pageview':
setupPageviewTrigger();
break;
case 'scroll_50':
setupScrollTrigger(50);
break;
case 'scroll_75':
setupScrollTrigger(75);
break;
case 'exit_intent':
setupExitIntentTrigger();
break;
case 'time_delay':
setupTimeDelayTrigger();
break;
}
}
// =====================================================
// INICIALIZACION
// =====================================================
function init() {
initAnchorAds();
initVignetteAds();
}
// Ejecutar cuando el DOM este listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();