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,145 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress;
use ROITheme\Public\AdsensePlacement\Application\UseCases\CheckAdsenseVisibilityUseCase;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST Controller para el endpoint de visibilidad de AdSense.
*
* Infrastructure Layer - Maneja HTTP y traduce a/desde Domain.
*
* Endpoint: GET /wp-json/roi-theme/v1/adsense-placement/visibility
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress
*/
final class AdsenseVisibilityController
{
private const NAMESPACE = 'roi-theme/v1';
private const ROUTE = '/adsense-placement/visibility';
private const NONCE_ACTION = 'roi_adsense_visibility';
public function __construct(
private CheckAdsenseVisibilityUseCase $useCase
) {
}
/**
* Registra la ruta REST del endpoint.
*/
public function registerRoutes(): void
{
register_rest_route(self::NAMESPACE, self::ROUTE, [
'methods' => 'GET',
'callback' => [$this, 'handleRequest'],
'permission_callback' => '__return_true',
'args' => [
'post_id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
// IMPORTANTE: postId=0 es valido (paginas de archivo, home, etc.)
'validate_callback' => static fn($value): bool => $value >= 0,
],
'nonce' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
}
/**
* Maneja la peticion REST.
*/
public function handleRequest(WP_REST_Request $request): WP_REST_Response
{
$this->sendNoCacheHeaders();
// Validar nonce si se proporciona
$nonce = $request->get_param('nonce');
if ($nonce !== null && !wp_verify_nonce($nonce, self::NONCE_ACTION)) {
return new WP_REST_Response([
'show_ads' => false,
'reasons' => ['invalid_nonce'],
'cache_seconds' => 0,
'timestamp' => time(),
], 403);
}
$postId = (int) $request->get_param('post_id');
$userContext = $this->buildUserContext();
$decision = $this->useCase->execute($postId, $userContext);
// El timestamp se inyecta aqui (Infrastructure) para mantener Domain puro
return new WP_REST_Response($decision->toArray(time()), 200);
}
/**
* Construye UserContext desde el estado actual de WordPress.
*/
private function buildUserContext(): UserContext
{
$isLoggedIn = is_user_logged_in();
$userRoles = [];
if ($isLoggedIn) {
$user = wp_get_current_user();
$userRoles = array_map(
static fn(string $role): int => self::roleToId($role),
$user->roles
);
}
// isMobile se determina por el cliente, no el servidor
// El cliente enviara esta info, pero por defecto asumimos false
$isMobile = false;
return new UserContext(
isLoggedIn: $isLoggedIn,
isMobile: $isMobile,
userRoles: $userRoles
);
}
/**
* Convierte nombre de rol a ID numerico para consistencia.
*/
private static function roleToId(string $role): int
{
$roleMap = [
'administrator' => 1,
'editor' => 2,
'author' => 3,
'contributor' => 4,
'subscriber' => 5,
];
return $roleMap[$role] ?? 0;
}
/**
* Envia headers para prevenir cache en proxies/CDNs.
*/
private function sendNoCacheHeaders(): void
{
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
header('Vary: Cookie');
}
/**
* Obtiene la accion del nonce para generacion en frontend.
*/
public static function getNonceAction(): string
{
return self::NONCE_ACTION;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Providers;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Infrastructure\Services\AdsenseVisibilityChecker;
use ROITheme\Public\AdsensePlacement\Application\UseCases\CheckAdsenseVisibilityUseCase;
use ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress\AdsenseVisibilityController;
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsenseAssetsEnqueuer;
/**
* Service Provider para el sistema JavaScript-First de AdSense.
*
* Registra todas las dependencias y hooks necesarios.
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Providers
*/
final class AdsenseJavascriptFirstServiceProvider
{
private ?AdsenseVisibilityCheckerInterface $visibilityChecker = null;
private ?CheckAdsenseVisibilityUseCase $useCase = null;
private ?AdsenseVisibilityController $controller = null;
private ?AdsenseAssetsEnqueuer $enqueuer = null;
public function __construct(
private DIContainer $container
) {
}
/**
* Registra los servicios en el contenedor.
*
* Llamar en after_setup_theme.
*/
public function register(): void
{
// Los servicios se crean lazy en boot()
}
/**
* Inicializa los hooks de WordPress.
*
* Llamar en init.
*/
public function boot(): void
{
// Registrar REST API endpoint
add_action('rest_api_init', function(): void {
$this->getController()->registerRoutes();
});
// Registrar enqueue de assets
$this->getEnqueuer()->register();
}
/**
* Obtiene el checker de visibilidad (lazy initialization).
*/
public function getVisibilityChecker(): AdsenseVisibilityCheckerInterface
{
if ($this->visibilityChecker === null) {
$this->visibilityChecker = new AdsenseVisibilityChecker(
$this->container->getComponentSettingsRepository()
);
}
return $this->visibilityChecker;
}
/**
* Obtiene el use case (lazy initialization).
*/
public function getUseCase(): CheckAdsenseVisibilityUseCase
{
if ($this->useCase === null) {
$this->useCase = new CheckAdsenseVisibilityUseCase(
$this->getVisibilityChecker()
);
}
return $this->useCase;
}
/**
* Obtiene el controller REST (lazy initialization).
*/
public function getController(): AdsenseVisibilityController
{
if ($this->controller === null) {
$this->controller = new AdsenseVisibilityController(
$this->getUseCase()
);
}
return $this->controller;
}
/**
* Obtiene el enqueuer de assets (lazy initialization).
*/
public function getEnqueuer(): AdsenseAssetsEnqueuer
{
if ($this->enqueuer === null) {
$this->enqueuer = new AdsenseAssetsEnqueuer(
$this->container->getComponentSettingsRepository()
);
}
return $this->enqueuer;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Services;
use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\AdsenseSettings;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* Implementacion del checker de visibilidad de AdSense.
*
* Infrastructure Layer - Accede a BD via repository.
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Services
*/
final class AdsenseVisibilityChecker implements AdsenseVisibilityCheckerInterface
{
private const COMPONENT_NAME = 'adsense-placement';
private const CACHE_SECONDS_SHOW = 300; // 5 min cuando se muestran ads
private const CACHE_SECONDS_HIDE = 600; // 10 min cuando se ocultan (menos volatil)
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {
}
public function check(int $postId, UserContext $userContext): VisibilityDecision
{
// Cargar configuracion desde BD
$rawSettings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
$settings = AdsenseSettings::fromArray($rawSettings);
// Evaluar reglas de visibilidad
$reasons = [];
// 1. AdSense desactivado globalmente
if (!$settings->isEnabled()) {
return VisibilityDecision::hide(['adsense_disabled'], self::CACHE_SECONDS_HIDE);
}
// 2. JavaScript-First Mode desactivado (usar PHP legacy)
if (!$settings->isJavascriptFirstMode()) {
return VisibilityDecision::hide(['javascript_first_disabled'], self::CACHE_SECONDS_HIDE);
}
// 3. Ocultar para usuarios logueados
if ($settings->hideForLoggedIn() && $userContext->isLoggedIn()) {
$reasons[] = 'user_logged_in';
}
// 4. Verificar dispositivo
if ($userContext->isMobile() && !$settings->showOnMobile()) {
$reasons[] = 'mobile_disabled';
}
if ($userContext->isDesktop() && !$settings->showOnDesktop()) {
$reasons[] = 'desktop_disabled';
}
// 5. Verificar exclusiones de post (solo si postId > 0)
if ($postId > 0) {
if ($settings->isPostExcluded($postId)) {
$reasons[] = 'post_excluded';
}
// Verificar categorias del post
$postCategories = $this->getPostCategoryIds($postId);
foreach ($postCategories as $catId) {
if ($settings->isCategoryExcluded($catId)) {
$reasons[] = 'category_excluded';
break;
}
}
// Verificar post type
$postType = $this->getPostType($postId);
if ($postType !== '' && $settings->isPostTypeExcluded($postType)) {
$reasons[] = 'post_type_excluded';
}
}
// Decision final
if (count($reasons) > 0) {
return VisibilityDecision::hide($reasons, self::CACHE_SECONDS_HIDE);
}
return VisibilityDecision::show(self::CACHE_SECONDS_SHOW);
}
/**
* Obtiene IDs de categorias de un post.
*
* @return array<int>
*/
private function getPostCategoryIds(int $postId): array
{
$categories = get_the_category($postId);
if (!is_array($categories)) {
return [];
}
return array_map(
static fn($cat): int => (int) $cat->term_id,
$categories
);
}
/**
* Obtiene el post type de un post.
*/
private function getPostType(int $postId): string
{
$postType = get_post_type($postId);
return $postType !== false ? $postType : '';
}
}

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