feat(api): implement javascript-first architecture for cache compatibility

- Add REST endpoint GET /roi-theme/v1/adsense-placement/visibility
- Add Domain layer: UserContext, VisibilityDecision, AdsenseSettings VOs
- Add Application layer: CheckAdsenseVisibilityUseCase
- Add Infrastructure: AdsenseVisibilityChecker, Controller, Enqueuer
- Add JavaScript controller with localStorage caching
- Add test plan for production validation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-11 13:03:14 -06:00
parent 8936670451
commit 26546e1d69
13 changed files with 1743 additions and 1 deletions

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress\AdsenseVisibilityController;
/**
* Encola los assets JavaScript para el modo JavaScript-First de AdSense.
*
* Infrastructure Layer - Integra con WordPress asset system.
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Ui
*/
final class AdsenseAssetsEnqueuer
{
private const COMPONENT_NAME = 'adsense-placement';
private const SCRIPT_HANDLE = 'roi-adsense-visibility';
private const SCRIPT_VERSION = '1.0.0';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {
}
/**
* Registra el hook para encolar scripts.
*/
public function register(): void
{
add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
}
/**
* Encola el script de visibilidad si el modo JS-First esta activo.
*/
public function enqueueScripts(): void
{
// Verificar si el modo JS-First esta activo
if (!$this->isJavascriptFirstModeEnabled()) {
return;
}
// Solo en frontend, no en admin
if (is_admin()) {
return;
}
$scriptPath = $this->getScriptPath();
// Verificar que el archivo existe
if (!file_exists($scriptPath)) {
return;
}
$scriptUrl = $this->getScriptUrl();
wp_enqueue_script(
self::SCRIPT_HANDLE,
$scriptUrl,
[], // Sin dependencias
self::SCRIPT_VERSION,
true // En footer
);
// Pasar configuracion al script
wp_localize_script(self::SCRIPT_HANDLE, 'roiAdsenseConfig', $this->getScriptConfig());
}
/**
* Verifica si el modo JavaScript-First esta activo.
*/
private function isJavascriptFirstModeEnabled(): bool
{
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
$isEnabled = (bool) ($settings['visibility']['is_enabled'] ?? false);
$jsFirstMode = (bool) ($settings['behavior']['javascript_first_mode'] ?? false);
return $isEnabled && $jsFirstMode;
}
/**
* Obtiene la ruta fisica del script.
*/
private function getScriptPath(): string
{
return get_template_directory() . '/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js';
}
/**
* Obtiene la URL del script.
*/
private function getScriptUrl(): string
{
return get_template_directory_uri() . '/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js';
}
/**
* Obtiene la configuracion para pasar al script.
*
* @return array<string, mixed>
*/
private function getScriptConfig(): array
{
$postId = $this->getCurrentPostId();
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
return [
'endpoint' => rest_url('roi-theme/v1/adsense-placement/visibility'),
'postId' => $postId,
'nonce' => wp_create_nonce(AdsenseVisibilityController::getNonceAction()),
'settingsVersion' => $settings['_meta']['version'] ?? '1.0.0',
'debug' => defined('WP_DEBUG') && WP_DEBUG,
'featureEnabled' => true,
'fallbackStrategy' => 'cached-or-show', // cached-or-show | cached-or-hide | always-show
];
}
/**
* Obtiene el ID del post actual (0 si no es un post singular).
*/
private function getCurrentPostId(): int
{
if (is_singular()) {
return get_the_ID() ?: 0;
}
return 0;
}
}

View File

@@ -0,0 +1,272 @@
/**
* ROI Theme - AdSense JavaScript-First Visibility Controller
*
* Mueve las decisiones de visibilidad de anuncios del servidor (PHP) al cliente (JS)
* para permitir compatibilidad con cache de pagina mientras mantiene personalizacion
* por usuario.
*
* @version 1.0.0
* @see openspec/specs/adsense-javascript-first/spec.md
*/
(function() {
'use strict';
const VERSION = '1.0.0';
const CACHE_KEY = 'roi_adsense_visibility';
const CACHE_VERSION_KEY = 'roi_adsense_settings_version';
// Configuracion inyectada por PHP via wp_localize_script
const config = window.roiAdsenseConfig || {};
/**
* Logger condicional (solo en modo debug)
*/
function log(message, type = 'log') {
if (!config.debug) return;
const prefix = '[ROI AdSense v' + VERSION + ']';
console[type](prefix, message);
}
/**
* Detecta si el dispositivo es movil basado en viewport
*/
function isMobile() {
return window.innerWidth < 992;
}
/**
* Obtiene decision cacheada de localStorage
*/
function getCachedDecision() {
try {
const cached = localStorage.getItem(CACHE_KEY);
const cachedVersion = localStorage.getItem(CACHE_VERSION_KEY);
if (!cached) {
log('No hay decision en cache');
return null;
}
// Invalidar si la version de settings cambio
if (cachedVersion !== config.settingsVersion) {
log('Version de settings cambio, invalidando cache');
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_VERSION_KEY);
return null;
}
const data = JSON.parse(cached);
// Verificar expiracion
const now = Math.floor(Date.now() / 1000);
const expiresAt = data.timestamp + data.cache_seconds;
if (now > expiresAt) {
log('Cache expirado');
localStorage.removeItem(CACHE_KEY);
return null;
}
log('Usando decision cacheada: ' + (data.show_ads ? 'MOSTRAR' : 'OCULTAR'));
return data;
} catch (e) {
log('Error leyendo cache: ' + e.message, 'error');
return null;
}
}
/**
* Guarda decision en localStorage
*/
function cacheDecision(decision) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(decision));
localStorage.setItem(CACHE_VERSION_KEY, config.settingsVersion);
log('Decision cacheada por ' + decision.cache_seconds + 's');
} catch (e) {
log('Error guardando cache: ' + e.message, 'warn');
}
}
/**
* Consulta el endpoint REST para obtener decision de visibilidad
*/
async function fetchVisibilityDecision() {
const url = new URL(config.endpoint);
url.searchParams.append('post_id', config.postId);
if (config.nonce) {
url.searchParams.append('nonce', config.nonce);
}
log('Consultando endpoint: ' + url.toString());
const response = await fetch(url.toString(), {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return await response.json();
}
/**
* Activa los anuncios (muestra placeholders, carga AdSense)
*/
function activateAds() {
log('Activando anuncios');
// Remover clase de oculto de los containers
document.querySelectorAll('.roi-adsense-placeholder').forEach(function(el) {
el.classList.remove('roi-adsense-hidden');
el.classList.add('roi-adsense-active');
});
// Disparar evento para que otros scripts puedan reaccionar
document.dispatchEvent(new CustomEvent('roiAdsenseActivated', {
detail: { version: VERSION }
}));
}
/**
* Desactiva los anuncios (oculta placeholders)
*/
function deactivateAds(reasons) {
log('Desactivando anuncios. Razones: ' + reasons.join(', '));
// Agregar clase de oculto a los containers
document.querySelectorAll('.roi-adsense-placeholder').forEach(function(el) {
el.classList.add('roi-adsense-hidden');
el.classList.remove('roi-adsense-active');
});
// Disparar evento
document.dispatchEvent(new CustomEvent('roiAdsenseDeactivated', {
detail: { reasons: reasons, version: VERSION }
}));
}
/**
* Aplica decision de visibilidad
*/
function applyDecision(decision) {
if (decision.show_ads) {
activateAds();
} else {
deactivateAds(decision.reasons || []);
}
}
/**
* Maneja error segun estrategia de fallback configurada
*/
function handleError(error) {
log('Error: ' + error.message, 'error');
const cached = getCachedDecision();
switch (config.fallbackStrategy) {
case 'cached-or-show':
if (cached) {
log('Usando cache como fallback');
applyDecision(cached);
} else {
log('Sin cache, mostrando ads por defecto (proteger revenue)');
activateAds();
}
break;
case 'cached-or-hide':
if (cached) {
log('Usando cache como fallback');
applyDecision(cached);
} else {
log('Sin cache, ocultando ads por defecto');
deactivateAds(['fallback_no_cache']);
}
break;
case 'always-show':
log('Fallback: siempre mostrar');
activateAds();
break;
default:
log('Estrategia desconocida, mostrando ads');
activateAds();
}
}
/**
* Funcion principal de inicializacion
*/
async function init() {
log('Inicializando...');
// Verificar que el feature este habilitado
if (!config.featureEnabled) {
log('Feature deshabilitado, usando modo legacy', 'warn');
return;
}
// IMPORTANTE: postId = 0 es valido (paginas de archivo, home, etc.)
// Solo validar que endpoint exista y postId no sea undefined/null
if (!config.endpoint || config.postId === undefined || config.postId === null) {
log('Sin endpoint configurado, activando ads', 'warn');
activateAds();
return;
}
// Intentar usar cache primero
const cached = getCachedDecision();
if (cached) {
applyDecision(cached);
return;
}
// Consultar endpoint
try {
const decision = await fetchVisibilityDecision();
log('Respuesta del servidor: ' + JSON.stringify(decision));
// Cachear decision
cacheDecision(decision);
// Aplicar decision
applyDecision(decision);
} catch (error) {
handleError(error);
}
}
// Ejecutar cuando el DOM este listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Exponer API publica para debugging
window.roiAdsenseVisibility = {
version: VERSION,
getConfig: function() { return config; },
getCachedDecision: getCachedDecision,
clearCache: function() {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_VERSION_KEY);
log('Cache limpiado');
},
forceRefresh: async function() {
this.clearCache();
await init();
}
};
})();