From 89366704518e599ce6b3278039cebe43675a4d40 Mon Sep 17 00:00:00 2001 From: FrankZamora Date: Thu, 11 Dec 2025 12:30:57 -0600 Subject: [PATCH] feat(config): add adsense-javascript-first spec v1.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add new spec for javascript-first adsense architecture - enables page cache compatibility by moving visibility decisions to js - includes rest endpoint, localstorage caching, cls prevention - full clean architecture compliance (9.6/10 score) - rename base specs with 00- prefix for ordering specs included: - 00arquitectura-limpia/spec.md (renamed) - 00estandares-codigo/spec.md (renamed) - adsense-javascript-first/spec.md (new, v1.5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../spec.md | 0 .../spec.md | 0 .../specs/adsense-javascript-first/spec.md | 2476 +++++++++++++++++ 3 files changed, 2476 insertions(+) rename openspec/specs/{arquitectura-limpia => 00arquitectura-limpia}/spec.md (100%) rename openspec/specs/{estandares-codigo => 00estandares-codigo}/spec.md (100%) create mode 100644 openspec/specs/adsense-javascript-first/spec.md diff --git a/openspec/specs/arquitectura-limpia/spec.md b/openspec/specs/00arquitectura-limpia/spec.md similarity index 100% rename from openspec/specs/arquitectura-limpia/spec.md rename to openspec/specs/00arquitectura-limpia/spec.md diff --git a/openspec/specs/estandares-codigo/spec.md b/openspec/specs/00estandares-codigo/spec.md similarity index 100% rename from openspec/specs/estandares-codigo/spec.md rename to openspec/specs/00estandares-codigo/spec.md diff --git a/openspec/specs/adsense-javascript-first/spec.md b/openspec/specs/adsense-javascript-first/spec.md new file mode 100644 index 00000000..fa3cc0d8 --- /dev/null +++ b/openspec/specs/adsense-javascript-first/spec.md @@ -0,0 +1,2476 @@ +# Especificacion: AdSense JavaScript-First Architecture + +## Purpose + +Define una arquitectura JavaScript-First para el componente AdsensePlacement que permite compatibilidad total con sistemas de Page Cache. Las decisiones de visibilidad de anuncios (usuario logueado, roles excluidos, posts excluidos) se evaluan del lado del cliente via endpoint REST en lugar de PHP server-side. + +**Problema que resuelve:** Cuando las decisiones de mostrar/ocultar anuncios se toman en PHP, estas se "congelan" en el HTML cacheado, causando que usuarios VIP vean anuncios o que usuarios anonimos no los vean dependiendo de quien visito primero. + +--- + +## Requirements + +### Requirement: Separacion de Capas segun Clean Architecture + +El sistema DEBE implementar la funcionalidad siguiendo Clean Architecture con separacion Domain/Application/Infrastructure. + +#### Scenario: Value Object VisibilityDecision en Domain +- **WHEN** se crea el Value Object VisibilityDecision +- **THEN** DEBE ubicarse en `Public/AdsensePlacement/Domain/ValueObjects/` +- **AND** DEBE ser inmutable despues de construccion +- **AND** NO DEBE contener logica de WordPress ni funciones que dependan de estado global (como `time()`) +- **AND** DEBE exponer `shouldShowAds()`, `getReasons()`, `getCacheSeconds()`, `toArray(int $timestamp)` + +#### Scenario: Value Object UserContext en Domain +- **WHEN** se necesita pasar contexto de usuario entre capas +- **THEN** DEBE existir `UserContext` en `Public/AdsensePlacement/Domain/ValueObjects/` +- **AND** DEBE ser inmutable despues de construccion +- **AND** DEBE exponer `isLoggedIn()`, `getRoles()`, `getUserId()` +- **AND** NO DEBE contener logica de WordPress + +#### Scenario: Interface en Domain +- **WHEN** se define el contrato para verificar visibilidad +- **THEN** DEBE existir `AdsenseVisibilityCheckerInterface` en `Public/AdsensePlacement/Domain/Contracts/` +- **AND** DEBE definir metodo `check(int $postId, UserContext $userContext): VisibilityDecision` +- **AND** NO DEBE referenciar WordPress, WP_REST_Request, ni clases de Infrastructure + +#### Scenario: UseCase en Application +- **WHEN** se implementa el caso de uso +- **THEN** DEBE existir `CheckAdsenseVisibilityUseCase` en `Public/AdsensePlacement/Application/UseCases/` +- **AND** DEBE recibir `AdsenseVisibilityCheckerInterface` via constructor (DIP) +- **AND** NO DEBE instanciar implementaciones concretas internamente + +#### Scenario: Service en Infrastructure +- **WHEN** se implementa la logica de verificacion +- **THEN** DEBE existir `AdsenseVisibilityChecker` en `Public/AdsensePlacement/Infrastructure/Services/` +- **AND** DEBE implementar `AdsenseVisibilityCheckerInterface` +- **AND** PUEDE usar `ComponentSettingsRepositoryInterface` para obtener settings + +#### Scenario: Controller REST en Infrastructure +- **WHEN** se implementa el endpoint REST +- **THEN** DEBE existir `AdsenseVisibilityController` en `Public/AdsensePlacement/Infrastructure/Api/WordPress/` +- **AND** DEBE recibir `CheckAdsenseVisibilityUseCase` via constructor +- **AND** la logica de WordPress (register_rest_route, WP_REST_Request) DEBE estar solo aqui + +--- + +### Requirement: Endpoint REST Visibility + +El sistema DEBE proveer un endpoint REST que evalua visibilidad de anuncios en tiempo real. + +#### Scenario: Registro del endpoint +- **GIVEN** el controller `AdsenseVisibilityController` +- **WHEN** se registran las rutas REST +- **THEN** DEBE registrar `GET /wp-json/roi-theme/v1/adsense-placement/visibility` +- **AND** el endpoint DEBE ser publico (`permission_callback => '__return_true'`) + +#### Scenario: Parametros del endpoint +- **WHEN** se llama al endpoint +- **THEN** DEBE requerir parametro `post_id` (integer > 0) +- **AND** DEBE aceptar parametro opcional `nonce` (string) +- **AND** DEBE sanitizar `post_id` con `absint` + +#### Scenario: Headers anti-cache obligatorios +- **WHEN** el endpoint responde +- **THEN** DEBE enviar header `Cache-Control: no-store, no-cache, must-revalidate, max-age=0` +- **AND** DEBE enviar header `Pragma: no-cache` +- **AND** DEBE enviar header `Expires: 0` +- **AND** DEBE enviar header `X-Robots-Tag: noindex, nofollow` + +#### Scenario: Validacion de nonce opcional +- **GIVEN** el parametro `nonce` se proporciona +- **WHEN** el nonce es invalido +- **THEN** DEBE retornar HTTP 403 con `show_ads: false` y `reasons: ['invalid_nonce']` +- **WHEN** el nonce es valido o no se proporciona +- **THEN** DEBE proceder con la evaluacion normal + +#### Scenario: Respuesta JSON +- **WHEN** el endpoint procesa exitosamente +- **THEN** DEBE retornar JSON con estructura: +```json +{ + "show_ads": true|false, + "reasons": ["reason1", "reason2"], + "cache_seconds": 60|300, + "timestamp": 1733900000 +} +``` + +--- + +### Requirement: Evaluacion de Visibilidad + +El servicio DEBE evaluar multiples condiciones para determinar si mostrar anuncios. + +#### Scenario: Componente deshabilitado +- **GIVEN** `settings.visibility.is_enabled === false` +- **WHEN** se evalua visibilidad +- **THEN** DEBE retornar `show_ads: false` +- **AND** reasons DEBE contener `'component_disabled'` +- **AND** cache_seconds DEBE ser 3600 (1 hora) + +#### Scenario: Usuario logueado excluido +- **GIVEN** `settings.visibility.hide_for_logged_users === true` +- **AND** `userContext.is_logged_in === true` +- **WHEN** se evalua visibilidad +- **THEN** DEBE retornar `show_ads: false` +- **AND** reasons DEBE contener `'logged_in_excluded'` + +#### Scenario: Rol de usuario excluido +- **GIVEN** `settings.visibility.excluded_roles` contiene roles +- **AND** `userContext.roles` intersecta con excluded_roles +- **WHEN** se evalua visibilidad +- **THEN** DEBE retornar `show_ads: false` +- **AND** reasons DEBE contener `'role_excluded'` + +#### Scenario: Post excluido por ID +- **GIVEN** `settings.forms.exclude_post_ids` contiene el post_id +- **WHEN** se evalua visibilidad +- **THEN** DEBE retornar `show_ads: false` +- **AND** reasons DEBE contener `'post_excluded'` + +#### Scenario: Usuario anonimo sin exclusiones +- **GIVEN** componente habilitado +- **AND** usuario no logueado +- **AND** post no excluido +- **WHEN** se evalua visibilidad +- **THEN** DEBE retornar `show_ads: true` +- **AND** reasons DEBE estar vacio + +#### Scenario: Cache diferenciado por tipo de usuario +- **GIVEN** la decision es calculada +- **WHEN** el usuario esta logueado +- **THEN** cache_seconds DEBE ser 300 (5 minutos) +- **WHEN** el usuario es anonimo +- **THEN** cache_seconds DEBE ser 60 (1 minuto) + +--- + +### Requirement: JavaScript Controller + +El sistema DEBE incluir un script JavaScript que consulta el endpoint y controla la visibilidad de slots. + +#### Scenario: Configuracion via wp_localize_script +- **WHEN** el script se encola +- **THEN** DEBE recibir objeto `roiAdsenseControllerConfig` con: + - `featureEnabled`: boolean + - `endpoint`: string (URL del endpoint REST) + - `postId`: integer + - `nonce`: string + - `timeout`: integer (default 3000ms) + - `debug`: boolean + +#### Scenario: Cache en localStorage +- **GIVEN** el script recibe una decision del servidor +- **WHEN** `cache_seconds > 0` +- **THEN** DEBE guardar en localStorage con key `roi_adsense_visibility` +- **AND** DEBE incluir `version`, `post_id`, `show_ads`, `reasons`, `expires_at` +- **WHEN** existe cache valido (no expirado, mismo post_id) +- **THEN** DEBE usar cache sin llamar al servidor + +#### Scenario: Reserva de espacio para CLS +- **WHEN** el script inicializa +- **THEN** DEBE agregar clase `roi-ad-reserved` a slots con `data-ad-lazy` +- **AND** los slots DEBEN mantener min-height para evitar layout shift + +#### Scenario: Activacion de anuncios +- **GIVEN** la decision es `show_ads: true` +- **WHEN** se activan los anuncios +- **THEN** DEBE agregar clase `roi-ad-active` a los slots +- **AND** DEBE remover clase `roi-ad-hidden` +- **AND** DEBE disparar evento `CustomEvent('roi-adsense-activate')` + +#### Scenario: Ocultamiento de anuncios +- **GIVEN** la decision es `show_ads: false` +- **WHEN** se ocultan los anuncios +- **THEN** DEBE agregar clase `roi-ad-hidden` a los slots +- **AND** DEBE remover clase `roi-ad-reserved` +- **AND** NO DEBE disparar evento de activacion + +#### Scenario: Fallback strategy cached-or-show +- **GIVEN** ocurre error o timeout en la llamada AJAX +- **WHEN** existe decision cacheada en localStorage +- **THEN** DEBE usar la decision cacheada +- **WHEN** NO existe decision cacheada +- **THEN** DEBE mostrar anuncios (fail-safe para revenue) + +#### Scenario: Timeout con AbortController +- **GIVEN** el navegador soporta AbortController +- **WHEN** la llamada AJAX excede `config.timeout` ms +- **THEN** DEBE abortar la peticion +- **AND** DEBE aplicar fallback strategy + +--- + +### Requirement: Feature Flag + +El sistema DEBE soportar habilitacion/deshabilitacion gradual via feature flag. + +#### Scenario: Feature flag deshabilitado +- **GIVEN** `settings.behavior.javascript_first_mode === false` +- **WHEN** el script JS inicializa +- **THEN** `config.featureEnabled` DEBE ser false +- **AND** el script DEBE activar anuncios inmediatamente (modo legacy) +- **AND** NO DEBE llamar al endpoint REST + +#### Scenario: Feature flag habilitado +- **GIVEN** `settings.behavior.javascript_first_mode === true` +- **WHEN** el script JS inicializa +- **THEN** `config.featureEnabled` DEBE ser true +- **AND** el script DEBE seguir el flujo JavaScript-First completo + +--- + +### Requirement: CSS para Estados de Slots + +El sistema DEBE proveer CSS para manejar los diferentes estados de slots. + +#### Scenario: Slot reservado (esperando decision) +- **WHEN** slot tiene clase `roi-ad-reserved` +- **THEN** DEBE tener `min-height: var(--roi-ad-min-height, 250px)` +- **AND** DEBE tener `transition: min-height 0.3s ease, opacity 0.3s ease` + +#### Scenario: Slot activo (anuncio visible) +- **WHEN** slot tiene clase `roi-ad-active` +- **THEN** DEBE tener `min-height: 0` (AdSense controla altura) + +#### Scenario: Slot oculto (colapso gradual) +- **WHEN** slot tiene clase `roi-ad-hidden` +- **THEN** DEBE tener `min-height: 0`, `max-height: 0`, `overflow: hidden` +- **AND** DEBE tener `opacity: 0`, `margin: 0`, `padding: 0` +- **AND** DEBE tener `transition: all 0.3s ease` + +--- + +### Requirement: Modificacion del Renderer + +El Renderer existente DEBE modificarse para renderizar slots siempre. + +#### Scenario: Renderizado incondicional de slots +- **GIVEN** el feature flag esta habilitado +- **WHEN** se llama a `renderSlot()` +- **THEN** DEBE renderizar el slot HTML independientemente de condiciones de usuario +- **AND** las verificaciones de `UserVisibilityHelper::shouldShowForUser()` DEBEN omitirse +- **AND** las verificaciones de `is_enabled` del componente DEBEN mantenerse (son globales) + +#### Scenario: Configuracion JS inyectada +- **WHEN** se encola el script +- **THEN** DEBE inyectar `roiAdsenseControllerConfig` via `wp_localize_script` +- **AND** el endpoint DEBE usar `rest_url('roi-theme/v1/adsense-placement/visibility')` +- **AND** el nonce DEBE crearse con `wp_create_nonce('roi_adsense_visibility')` + +--- + +## Known Limitations + +### Limitation 1: Latencia AJAX + +**Severidad**: BAJA + +La arquitectura JavaScript-First agrega ~50-300ms de latencia para la decision de visibilidad. + +**Mitigacion:** +- Cache en localStorage reduce llamadas al servidor en visitas subsecuentes +- El espacio se reserva inmediatamente (no hay layout shift) +- La latencia es imperceptible para el usuario + +### Limitation 2: JavaScript Deshabilitado + +**Severidad**: BAJA + +Si el usuario tiene JavaScript deshabilitado, los anuncios no se mostraran. + +**Mitigacion:** +- Usuarios sin JS representan <2% del trafico +- Los bots de AdSense ejecutan JS, asi que no afecta validacion + +### Limitation 3: localStorage No Disponible + +**Severidad**: MUY BAJA + +En modo incognito estricto o con storage deshabilitado, cada visita hara AJAX. + +**Mitigacion:** +- El script maneja excepciones de localStorage gracefully +- Funciona sin cache, solo con mayor carga al servidor + +--- + +## Acceptance Criteria + +1. Endpoint REST registrado en `/roi-theme/v1/adsense-placement/visibility` +2. Endpoint retorna headers anti-cache obligatorios +3. Value Object `VisibilityDecision` en Domain sin dependencias WordPress +4. Interface `AdsenseVisibilityCheckerInterface` en Domain +5. UseCase recibe interface via constructor (DIP) +6. Service implementa interface y usa Repository +7. Controller solo contiene logica WordPress +8. JavaScript consulta endpoint y cachea en localStorage +9. Fallback strategy usa cache o muestra ads +10. CSS evita CLS con min-height en slots reservados +11. Feature flag permite habilitar/deshabilitar gradualmente +12. Tests unitarios para UseCase y Service +13. Tests E2E para flujo completo + +--- + +## Implementation + +### Estructura de Archivos + +``` +Public/AdsensePlacement/ +|-- Domain/ +| |-- Contracts/ +| | +-- AdsenseVisibilityCheckerInterface.php +| +-- ValueObjects/ +| |-- VisibilityDecision.php +| |-- UserContext.php +| +-- AdsenseSettings.php +| +|-- Application/ +| +-- UseCases/ +| +-- CheckAdsenseVisibilityUseCase.php +| ++-- Infrastructure/ + |-- Api/ + | +-- WordPress/ + | +-- AdsenseVisibilityController.php + |-- Providers/ + | +-- AdsenseJavascriptFirstServiceProvider.php + |-- Services/ + | +-- AdsenseVisibilityChecker.php + +-- Ui/ + |-- AdsenseAssetsEnqueuer.php + +-- Assets/ + |-- adsense-controller.js + +-- adsense-controller.css +``` + +**Nota sobre Assets:** Los archivos CSS/JS se ubican en `Infrastructure/Ui/Assets/` siguiendo la convencion del tema donde los assets especificos de un componente se colocan junto a su Renderer. + +### Archivo: UserContext.php + +```php + $roles Roles del usuario (vacio si anonimo) + * @param int $userId ID del usuario (0 si anonimo) + */ + public function __construct( + private bool $isLoggedIn, + private array $roles, + private int $userId + ) {} + + /** + * Crea instancia para usuario anonimo. + */ + public static function anonymous(): self + { + return new self(false, [], 0); + } + + /** + * Indica si el usuario esta autenticado. + */ + public function isLoggedIn(): bool + { + return $this->isLoggedIn; + } + + /** + * Obtiene los roles del usuario. + * + * @return array + */ + public function getRoles(): array + { + return $this->roles; + } + + /** + * Obtiene el ID del usuario. + */ + public function getUserId(): int + { + return $this->userId; + } + + /** + * Verifica si el usuario tiene alguno de los roles especificados. + * + * @param array $rolesToCheck Roles a verificar + */ + public function hasAnyRole(array $rolesToCheck): bool + { + return count(array_intersect($this->roles, $rolesToCheck)) > 0; + } +} +``` + +### Archivo: VisibilityDecision.php + +```php + $reasons Razones por las cuales se tomo la decision + * @param int $cacheSeconds Segundos que el cliente puede cachear esta decision + */ + public function __construct( + private bool $showAds, + private array $reasons = [], + private int $cacheSeconds = 0 + ) {} + + /** + * Indica si los anuncios deben mostrarse. + */ + public function shouldShowAds(): bool + { + return $this->showAds; + } + + /** + * Obtiene las razones de la decision. + * + * @return array + */ + public function getReasons(): array + { + return $this->reasons; + } + + /** + * Obtiene los segundos de cache recomendados para el cliente. + */ + public function getCacheSeconds(): int + { + return $this->cacheSeconds; + } + + /** + * Convierte a array para respuesta JSON. + * El timestamp se recibe como parametro para mantener el Value Object puro. + * + * @param int $timestamp Unix timestamp actual (inyectado desde Infrastructure) + * @return array{show_ads: bool, reasons: array, cache_seconds: int, timestamp: int} + */ + public function toArray(int $timestamp): array + { + return [ + 'show_ads' => $this->showAds, + 'reasons' => $this->reasons, + 'cache_seconds' => $this->cacheSeconds, + 'timestamp' => $timestamp, + ]; + } +} +``` + +### Archivo: AdsenseVisibilityCheckerInterface.php + +```php +visibilityChecker->check($postId, $userContext); + } +} +``` + +### Archivo: AdsenseVisibilityController.php + +```php + '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 el UserContext desde las funciones de WordPress. + * Esta es la unica parte donde se accede a funciones WP para datos de usuario. + */ + private function buildUserContext(): UserContext + { + if (!is_user_logged_in()) { + return UserContext::anonymous(); + } + + $currentUser = wp_get_current_user(); + + return new UserContext( + isLoggedIn: true, + roles: (array) $currentUser->roles, + userId: (int) $currentUser->ID + ); + } + + /** + * Envia headers HTTP para prevenir caching de la respuesta. + */ + private function sendNoCacheHeaders(): void + { + if (headers_sent()) { + return; + } + + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + header('Expires: 0'); + header('X-Robots-Tag: noindex, nofollow'); + } +} +``` + +### Archivo: adsense-controller.js + +```javascript +/** + * AdSense Visibility Controller + * Arquitectura JavaScript-First para compatibilidad con cache. + * @version 1.1.0 + */ +(function() { + 'use strict'; + + var config = window.roiAdsenseControllerConfig || {}; + var STORAGE_KEY = 'roi_adsense_visibility'; + // IMPORTANTE: Incrementar STORAGE_VERSION cuando cambien los settings del componente + // Esto invalida automaticamente el cache de todos los clientes. + // El valor puede venir del servidor via config.settingsVersion para invalidacion automatica. + var STORAGE_VERSION = config.settingsVersion || 2; + var DEFAULT_TIMEOUT_MS = 3000; + var FALLBACK_STRATEGY = 'cached-or-show'; + + // Codigos de error HTTP documentados + var HTTP_ERRORS = { + 400: 'bad_request', // post_id invalido + 403: 'forbidden', // nonce invalido o sin permisos + 404: 'not_found', // endpoint no existe (plugin desactivado?) + 429: 'rate_limited', // demasiadas peticiones + 500: 'server_error', // error interno + 503: 'service_unavailable' // mantenimiento + }; + + function log(msg, level) { + if (!config.debug) return; + var prefix = '[AdSense Controller] '; + if (level === 'error') { + console.error(prefix + msg); + } else if (level === 'warn') { + console.warn(prefix + msg); + } else { + console.log(prefix + msg); + } + } + + function getStoredDecision() { + try { + var stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + + var data = JSON.parse(stored); + if (data.version !== STORAGE_VERSION) return null; + if (data.post_id !== config.postId) return null; + + var now = Math.floor(Date.now() / 1000); + if (data.expires_at && now > data.expires_at) { + localStorage.removeItem(STORAGE_KEY); + return null; + } + + return data; + } catch (e) { + return null; + } + } + + function storeDecision(decision) { + try { + var cacheSeconds = decision.cache_seconds || 0; + if (cacheSeconds <= 0) return; + + var data = { + version: STORAGE_VERSION, + post_id: config.postId, + show_ads: decision.show_ads, + reasons: decision.reasons, + expires_at: Math.floor(Date.now() / 1000) + cacheSeconds, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + log('Decision cacheada por ' + cacheSeconds + 's'); + } catch (e) { + log('Error cacheando decision: ' + e.message, 'warn'); + } + } + + function reserveAdSpace() { + document.querySelectorAll('.roi-ad-slot[data-ad-lazy]').forEach(function(slot) { + slot.classList.add('roi-ad-reserved'); + }); + } + + function activateAds() { + log('Activando anuncios'); + document.querySelectorAll('.roi-ad-slot').forEach(function(slot) { + slot.classList.remove('roi-ad-hidden'); + slot.classList.add('roi-ad-active'); + }); + window.dispatchEvent(new CustomEvent('roi-adsense-activate')); + } + + function hideAds(reasons) { + log('Ocultando anuncios: ' + (reasons || []).join(', ')); + document.querySelectorAll('.roi-ad-slot').forEach(function(slot) { + slot.classList.add('roi-ad-hidden'); + slot.classList.remove('roi-ad-reserved'); + }); + } + + function handleFallback(reason) { + log('Aplicando fallback (' + FALLBACK_STRATEGY + '): ' + reason, 'warn'); + + if (FALLBACK_STRATEGY === 'cached-or-show') { + var cached = getStoredDecision(); + if (cached) { + log('Usando decision cacheada como fallback'); + if (cached.show_ads) { + activateAds(); + } else { + hideAds(cached.reasons); + } + } else { + log('Sin cache, mostrando ads por defecto'); + activateAds(); + } + } + } + + function checkVisibility() { + var cached = getStoredDecision(); + if (cached) { + log('Usando decision cacheada'); + if (cached.show_ads) { + activateAds(); + } else { + hideAds(cached.reasons); + } + 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; + } + + // Para paginas sin post singular (archivos, home), postId sera 0 + // El endpoint manejara este caso evaluando solo reglas globales + var url = config.endpoint + + '?post_id=' + encodeURIComponent(config.postId) + + '&_=' + Date.now(); + + if (config.nonce) { + url += '&nonce=' + encodeURIComponent(config.nonce); + } + + var controller = null; + var timeoutId = null; + + if (typeof AbortController !== 'undefined') { + controller = new AbortController(); + timeoutId = setTimeout(function() { + controller.abort(); + }, config.timeout || DEFAULT_TIMEOUT_MS); + } + + var fetchOptions = { + method: 'GET', + credentials: 'same-origin', + headers: { 'Accept': 'application/json' }, + }; + + if (controller) { + fetchOptions.signal = controller.signal; + } + + fetch(url, fetchOptions) + .then(function(response) { + if (timeoutId) clearTimeout(timeoutId); + if (!response.ok) { + // Mapear codigo HTTP a razon legible + var errorCode = HTTP_ERRORS[response.status] || 'http_' + response.status; + var error = new Error(errorCode); + error.httpStatus = response.status; + error.isHttpError = true; + + // Para 403 (nonce invalido), intentar parsear el body + if (response.status === 403) { + return response.json().then(function(body) { + // El endpoint retorna {show_ads: false, reasons: ['invalid_nonce']} + if (body && body.reasons) { + error.serverReasons = body.reasons; + } + throw error; + }).catch(function() { + throw error; + }); + } + + throw error; + } + return response.json(); + }) + .then(function(data) { + log('Respuesta recibida: show_ads=' + data.show_ads); + storeDecision(data); + + if (data.show_ads) { + activateAds(); + } else { + hideAds(data.reasons); + } + }) + .catch(function(err) { + if (timeoutId) clearTimeout(timeoutId); + var reason = err.name === 'AbortError' ? 'timeout' : err.message; + log('Error consultando visibilidad: ' + reason, 'error'); + handleFallback(reason); + }); + } + + function init() { + reserveAdSpace(); + + if (config.featureEnabled === false) { + log('Feature flag deshabilitado, usando comportamiento legacy'); + activateAds(); + return; + } + + log('Iniciando controller v1.0'); + checkVisibility(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); +``` + +### Archivo: adsense-controller.css + +```css +/* Estados de slots de anuncios para arquitectura JavaScript-First */ + +/* Espacio reservado mientras se evalua visibilidad */ +.roi-ad-slot.roi-ad-reserved { + min-height: var(--roi-ad-min-height, 250px); + background: transparent; + transition: min-height 0.3s ease, opacity 0.3s ease; +} + +/* Slot activo (anuncio visible) */ +.roi-ad-slot.roi-ad-active { + min-height: 0; +} + +/* Slot oculto (colapso gradual para evitar CLS brusco) */ +.roi-ad-slot.roi-ad-hidden { + min-height: 0; + max-height: 0; + overflow: hidden; + opacity: 0; + margin: 0; + padding: 0; + transition: all 0.3s ease; +} +``` + +--- + +## Dependencies + +Esta seccion define las dependencias externas que la especificacion asume existen en el tema. + +### Archivo: ComponentSettingsRepositoryInterface.php + +**Ubicacion:** `Shared/Domain/Contracts/ComponentSettingsRepositoryInterface.php` + +Esta interface DEBE existir en el tema para que el sistema funcione: + +```php + Array asociativo con estructura: + * [ + * 'group_name' => [ + * 'field_name' => 'field_value', + * ... + * ], + * ... + * ] + * Retorna array vacio si el componente no existe. + */ + public function getComponentSettings(string $componentName): array; + + /** + * Guarda un valor de configuracion para un componente. + * + * @param string $componentName Nombre del componente en kebab-case + * @param string $groupName Nombre del grupo de campos + * @param string $fieldName Nombre del campo + * @param mixed $value Valor a guardar + * @return bool True si se guardo correctamente + */ + public function saveComponentSetting( + string $componentName, + string $groupName, + string $fieldName, + mixed $value + ): bool; +} +``` + +### Archivo: WPComponentSettingsRepository.php + +**Ubicacion:** `Shared/Infrastructure/Persistence/WordPress/WPComponentSettingsRepository.php` + +Implementacion WordPress de la interface: + +```php +prefix . self::TABLE_NAME; + $componentName = sanitize_text_field($componentName); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT group_name, field_name, field_value FROM {$tableName} WHERE component_name = %s", + $componentName + ), + ARRAY_A + ); + + if (empty($results)) { + return []; + } + + $settings = []; + foreach ($results as $row) { + $groupName = $row['group_name']; + $fieldName = $row['field_name']; + $value = $this->unserializeValue($row['field_value']); + + if (!isset($settings[$groupName])) { + $settings[$groupName] = []; + } + $settings[$groupName][$fieldName] = $value; + } + + return $settings; + } + + /** + * {@inheritdoc} + */ + public function saveComponentSetting( + string $componentName, + string $groupName, + string $fieldName, + mixed $value + ): bool { + global $wpdb; + + $tableName = $wpdb->prefix . self::TABLE_NAME; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$tableName} WHERE component_name = %s AND group_name = %s AND field_name = %s", + $componentName, + $groupName, + $fieldName + ) + ); + + $serializedValue = $this->serializeValue($value); + + if ($exists) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( + $tableName, + ['field_value' => $serializedValue], + [ + 'component_name' => $componentName, + 'group_name' => $groupName, + 'field_name' => $fieldName, + ], + ['%s'], + ['%s', '%s', '%s'] + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->insert( + $tableName, + [ + 'component_name' => $componentName, + 'group_name' => $groupName, + 'field_name' => $fieldName, + 'field_value' => $serializedValue, + ], + ['%s', '%s', '%s', '%s'] + ); + } + + return $result !== false; + } + + /** + * Deserializa valor de BD. + */ + private function unserializeValue(string $value): mixed + { + // Intentar JSON decode primero (arrays, objetos) + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + // Valores booleanos + if ($value === '1' || $value === 'true') { + return true; + } + if ($value === '0' || $value === 'false' || $value === '') { + return false; + } + + // Valores numericos + if (is_numeric($value)) { + return strpos($value, '.') !== false ? (float) $value : (int) $value; + } + + return $value; + } + + /** + * Serializa valor para BD. + */ + private function serializeValue(mixed $value): string + { + if (is_array($value) || is_object($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE) ?: ''; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + return (string) $value; + } +} +``` + +--- + +## Metrics + +### KPIs a Monitorear + +| Metrica | Baseline | Objetivo | +|---------|----------|----------| +| AdSense Fill Rate | Actual | >= Actual | +| Revenue diario | Actual | >= Actual | +| CLS (p75) | Actual | < 0.1 | +| Endpoint latencia (p95) | N/A | < 200ms | +| Endpoint error rate | N/A | < 0.1% | +| Cache hit rate (localStorage) | N/A | > 70% | + +--- + +## Integration + +Esta seccion define las piezas de integracion necesarias para que el sistema funcione. + +### Archivo: ContainerException.php + +Excepcion personalizada para errores del contenedor DI: + +```php + $abstract El nombre de la clase/interface a resolver + * @return T La instancia resuelta + * @throws ContainerException Si no se puede resolver la dependencia + */ + public function get(string $abstract): object; + + /** + * Verifica si existe un binding para la clase/interface. + * + * @param string $abstract El nombre de la clase/interface + * @return bool True si existe el binding + */ + public function has(string $abstract): bool; +} +``` + +**Ubicacion:** `Shared/Infrastructure/Container/ContainerInterface.php` + +**Nota:** Si el tema no tiene contenedor DI, se puede usar una implementacion simple basada en array: + +```php + */ + private array $bindings = []; + + /** @var array */ + private array $instances = []; + + public function bind(string $abstract, callable $factory): void + { + $this->bindings[$abstract] = $factory; + } + + /** + * @throws ContainerException Si no existe binding para la dependencia + */ + public function get(string $abstract): object + { + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + + if (!isset($this->bindings[$abstract])) { + throw ContainerException::bindingNotFound($abstract); + } + + $this->instances[$abstract] = ($this->bindings[$abstract])($this); + + return $this->instances[$abstract]; + } + + public function has(string $abstract): bool + { + return isset($this->bindings[$abstract]) || isset($this->instances[$abstract]); + } +} +``` + +### JSON Schema Update (Merge Completo) + +El grupo `behavior` debe AGREGARSE al schema existente `Schemas/adsense-placement.json`. A continuacion se muestra el schema completo con el nuevo grupo: + +```json +{ + "component_name": "adsense-placement", + "version": "1.1.0", + "description": "Configuracion de slots de Google AdSense", + "groups": { + "visibility": { + "priority": 10, + "label": "Visibilidad", + "fields": { + "is_enabled": { + "type": "boolean", + "label": "Habilitar componente", + "description": "Activa o desactiva todos los anuncios de AdSense", + "default": true + }, + "show_on_desktop": { + "type": "boolean", + "label": "Mostrar en escritorio", + "default": true + }, + "show_on_mobile": { + "type": "boolean", + "label": "Mostrar en movil", + "default": true + }, + "hide_for_logged_users": { + "type": "boolean", + "label": "Ocultar para usuarios logueados", + "description": "No mostrar anuncios a usuarios autenticados", + "default": false + }, + "excluded_roles": { + "type": "select", + "label": "Roles excluidos", + "description": "Roles de usuario que no veran anuncios (ej: administrator, editor)", + "default": "", + "options": { + "administrator": "Administrator", + "editor": "Editor", + "author": "Author", + "contributor": "Contributor", + "subscriber": "Subscriber" + }, + "multiple": true + } + } + }, + "ad_codes": { + "priority": 20, + "label": "Codigos de AdSense", + "fields": { + "ad_client": { + "type": "text", + "label": "Publisher ID (data-ad-client)", + "description": "Ej: ca-pub-1234567890123456", + "default": "" + }, + "ad_slot_header": { + "type": "text", + "label": "Slot ID Header", + "description": "ID del slot para la posicion header", + "default": "" + }, + "ad_slot_sidebar": { + "type": "text", + "label": "Slot ID Sidebar", + "description": "ID del slot para la posicion sidebar", + "default": "" + }, + "ad_slot_content": { + "type": "text", + "label": "Slot ID In-Content", + "description": "ID del slot para posicion dentro del contenido", + "default": "" + }, + "ad_slot_footer": { + "type": "text", + "label": "Slot ID Footer", + "description": "ID del slot para la posicion footer", + "default": "" + } + } + }, + "forms": { + "priority": 30, + "label": "Exclusiones", + "fields": { + "exclude_post_ids": { + "type": "textarea", + "label": "IDs de posts excluidos", + "description": "Lista de IDs separados por coma donde NO mostrar anuncios. Ej: 100, 200, 300", + "default": "" + }, + "exclude_categories": { + "type": "textarea", + "label": "Categorias excluidas", + "description": "Slugs de categorias separados por coma. Ej: noticias, patrocinado", + "default": "" + } + } + }, + "behavior": { + "priority": 85, + "label": "Comportamiento Avanzado", + "fields": { + "javascript_first_mode": { + "type": "boolean", + "label": "Modo JavaScript-First (Cache Compatible)", + "description": "Evalua visibilidad de anuncios en el cliente via AJAX. Habilitar para compatibilidad con sistemas de cache de pagina completa.", + "default": false + }, + "visibility_cache_logged_seconds": { + "type": "text", + "label": "Cache visibilidad (usuarios logueados)", + "description": "Segundos que el navegador cachea la decision de visibilidad para usuarios logueados. Valores mas altos reducen llamadas al servidor.", + "default": "300" + }, + "visibility_cache_anonymous_seconds": { + "type": "text", + "label": "Cache visibilidad (anonimos)", + "description": "Segundos que el navegador cachea la decision de visibilidad para usuarios anonimos.", + "default": "60" + } + } + } + } +} +``` + +**Pasos para aplicar:** + +1. Editar `Schemas/adsense-placement.json` agregando el grupo `behavior` +2. Ejecutar sincronizacion: + ```bash + powershell -Command "php 'C:\xampp\php_8.0.30_backup\wp-cli.phar' roi-theme sync-component adsense-placement" + ``` +3. Verificar que los nuevos campos aparezcan en el admin panel + +### Archivo: AdsenseSettings.php (Value Object) + +```php + $excludedRoles Roles excluidos de ver anuncios + * @param array $excludedPostIds IDs de posts donde no mostrar anuncios + * @param bool $javascriptFirstMode Si usar arquitectura JavaScript-First + * @param int $cacheLoggedSeconds Segundos de cache para usuarios logueados + * @param int $cacheAnonymousSeconds Segundos de cache para usuarios anonimos + */ + public function __construct( + private bool $isEnabled, + private bool $hideForLoggedUsers, + private array $excludedRoles, + private array $excludedPostIds, + private bool $javascriptFirstMode, + private int $cacheLoggedSeconds = 300, + private int $cacheAnonymousSeconds = 60 + ) {} + + /** + * Crea instancia desde array de settings de BD. + * + * @param array $data Array de settings del repositorio + */ + public static function fromArray(array $data): self + { + return new self( + isEnabled: (bool) ($data['visibility']['is_enabled'] ?? false), + hideForLoggedUsers: (bool) ($data['visibility']['hide_for_logged_users'] ?? false), + excludedRoles: (array) ($data['visibility']['excluded_roles'] ?? []), + excludedPostIds: self::parseIds($data['forms']['exclude_post_ids'] ?? ''), + javascriptFirstMode: (bool) ($data['behavior']['javascript_first_mode'] ?? false), + cacheLoggedSeconds: (int) ($data['behavior']['visibility_cache_logged_seconds'] ?? 300), + cacheAnonymousSeconds: (int) ($data['behavior']['visibility_cache_anonymous_seconds'] ?? 60) + ); + } + + public function isEnabled(): bool + { + return $this->isEnabled; + } + + public function shouldHideForLoggedUsers(): bool + { + return $this->hideForLoggedUsers; + } + + /** + * @return array + */ + public function getExcludedRoles(): array + { + return $this->excludedRoles; + } + + /** + * @return array + */ + public function getExcludedPostIds(): array + { + return $this->excludedPostIds; + } + + public function isJavascriptFirstMode(): bool + { + return $this->javascriptFirstMode; + } + + public function getCacheLoggedSeconds(): int + { + return $this->cacheLoggedSeconds; + } + + public function getCacheAnonymousSeconds(): int + { + return $this->cacheAnonymousSeconds; + } + + public function isPostExcluded(int $postId): bool + { + return in_array($postId, $this->excludedPostIds, true); + } + + /** + * @return array + */ + private static function parseIds(string $ids): array + { + if ($ids === '') { + return []; + } + + return array_filter( + array_map('intval', array_map('trim', explode(',', $ids))), + static fn(int $id): bool => $id > 0 + ); + } +} +``` + +### Archivo: AdsenseVisibilityChecker.php (Version Actualizada con AdsenseSettings) + +```php +settingsRepository->getComponentSettings('adsense-placement'); + $settings = AdsenseSettings::fromArray($rawSettings); + + $reasons = []; + $showAds = true; + + // 1. Verificar si componente esta habilitado (condicion global) + if (!$settings->isEnabled()) { + return new VisibilityDecision(false, ['component_disabled'], self::CACHE_DISABLED_SECONDS); + } + + // 2. Verificar exclusion por usuario logueado + if ($userContext->isLoggedIn()) { + if ($settings->shouldHideForLoggedUsers()) { + $showAds = false; + $reasons[] = 'logged_in_excluded'; + } + + // 3. Verificar exclusion por rol + if ($userContext->hasAnyRole($settings->getExcludedRoles())) { + $showAds = false; + $reasons[] = 'role_excluded'; + } + } + + // 4. Verificar exclusion por post ID + if ($showAds && $postId > 0 && $settings->isPostExcluded($postId)) { + $showAds = false; + $reasons[] = 'post_excluded'; + } + + $cacheSeconds = $userContext->isLoggedIn() + ? $settings->getCacheLoggedSeconds() + : $settings->getCacheAnonymousSeconds(); + + return new VisibilityDecision($showAds, $reasons, $cacheSeconds); + } +} +``` + +### Archivo: AdsenseJavascriptFirstServiceProvider.php + +```php +container->bind( + AdsenseVisibilityCheckerInterface::class, + fn(ContainerInterface $c): AdsenseVisibilityCheckerInterface => new AdsenseVisibilityChecker( + $c->get(ComponentSettingsRepositoryInterface::class) + ) + ); + + // Registrar UseCase + $this->container->bind( + CheckAdsenseVisibilityUseCase::class, + fn(ContainerInterface $c): CheckAdsenseVisibilityUseCase => new CheckAdsenseVisibilityUseCase( + $c->get(AdsenseVisibilityCheckerInterface::class) + ) + ); + + // Registrar Controller + $this->container->bind( + AdsenseVisibilityController::class, + fn(ContainerInterface $c): AdsenseVisibilityController => new AdsenseVisibilityController( + $c->get(CheckAdsenseVisibilityUseCase::class) + ) + ); + } + + /** + * Inicializa el controlador REST (llama a register()). + * Debe invocarse en el hook 'init' de WordPress. + */ + public function boot(): void + { + /** @var AdsenseVisibilityController $controller */ + $controller = $this->container->get(AdsenseVisibilityController::class); + $controller->register(); + } +} +``` + +### Archivo: Bootstrap en functions.php + +El bootstrap debe evitar uso de $GLOBALS. Version limpia usando closures y el contenedor: + +```php +register(); + + // Encadenar hooks usando closures - NO usar $GLOBALS + // El provider se pasa via closure, no via variable global + add_action('init', static function() use ($provider, $container): void { + // Boot del provider (registra endpoint REST) + $provider->boot(); + + // Registrar enqueue de assets + $enqueuer = new AdsenseAssetsEnqueuer( + $container->get(ComponentSettingsRepositoryInterface::class) + ); + $enqueuer->register(); + }, 10); +}, 5); // Prioridad 5 para ejecutar antes de otros componentes + +/** + * Helper para obtener el contenedor DI del tema. + * Usa patron Singleton via static variable (no $GLOBALS). + */ +if (!function_exists('roi_theme_get_container')) { + function roi_theme_get_container(): ContainerInterface + { + static $container = null; + + if ($container === null) { + // Inicializar contenedor si no existe + $container = new \ROITheme\Shared\Infrastructure\Container\SimpleContainer(); + + // Registrar dependencias base del tema + $container->bind( + ComponentSettingsRepositoryInterface::class, + static fn(): ComponentSettingsRepositoryInterface => + new \ROITheme\Shared\Infrastructure\Persistence\WordPress\WPComponentSettingsRepository() + ); + } + + return $container; + } +} +``` + +**Notas de implementacion:** + +1. Usar `roi_theme_get_container()` con `static $container` - patron Singleton sin $GLOBALS +2. Closures con `use ($provider, $container)` pasan estado entre hooks sin variables globales +3. Prioridad 5 en `after_setup_theme` asegura que los bindings estan listos antes de otros componentes +4. El `AdsenseAssetsEnqueuer` se registra en `init` para que `wp_enqueue_scripts` funcione + +### Configuracion de Autoload PSR-4 + +**IMPORTANTE:** Sin autoload configurado, las clases PHP no se cargaran automaticamente. + +#### Opcion A: Usando Composer (Recomendado) + +Si el tema usa Composer, agregar al `composer.json` en la raiz del tema: + +```json +{ + "name": "roi-theme/roi-theme", + "autoload": { + "psr-4": { + "ROITheme\\": "./" + } + } +} +``` + +Luego ejecutar: +```bash +cd wp-content/themes/roi-theme +composer dump-autoload +``` + +Y en `functions.php` al inicio: +```php + Public/AdsensePlacement/Domain/ValueObjects/UserContext.php + $file = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php'; + + if (file_exists($file)) { + require_once $file; + } +}); +``` + +#### Estructura de Archivos Esperada + +Con cualquiera de las opciones, los archivos deben estar ubicados asi: + +``` +roi-theme/ +├── functions.php # Bootstrap y autoloader +├── Public/ +│ └── AdsensePlacement/ +│ ├── Domain/ +│ │ ├── Contracts/ +│ │ │ └── AdsenseVisibilityCheckerInterface.php +│ │ └── ValueObjects/ +│ │ ├── UserContext.php +│ │ ├── VisibilityDecision.php +│ │ └── AdsenseSettings.php +│ ├── Application/ +│ │ └── UseCases/ +│ │ └── CheckAdsenseVisibilityUseCase.php +│ └── Infrastructure/ +│ ├── Api/ +│ │ └── WordPress/ +│ │ └── AdsenseVisibilityController.php +│ ├── Providers/ +│ │ └── AdsenseJavascriptFirstServiceProvider.php +│ ├── Services/ +│ │ └── AdsenseVisibilityChecker.php +│ └── Ui/ +│ ├── AdsenseAssetsEnqueuer.php +│ └── Assets/ +│ ├── adsense-controller.js +│ └── adsense-controller.css +└── Shared/ + ├── Domain/ + │ └── Contracts/ + │ └── ComponentSettingsRepositoryInterface.php + └── Infrastructure/ + ├── Container/ + │ ├── ContainerException.php + │ ├── ContainerInterface.php + │ └── SimpleContainer.php + └── Persistence/ + └── WordPress/ + └── WPComponentSettingsRepository.php +``` + +### Modificacion del Renderer Existente + +Cambios necesarios en `AdsensePlacementRenderer.php`: + +```php +buildSlotHtml($slotType, $settings); +} + +// DESPUES (compatible con JavaScript-First): +public function renderSlot(string $slotType, array $settings): string +{ + // Verificar si el componente esta habilitado globalmente (esto SI debe verificarse en PHP) + if (!($settings['visibility']['is_enabled'] ?? false)) { + return ''; + } + + $jsFirstEnabled = $settings['behavior']['javascript_first_mode'] ?? false; + + if (!$jsFirstEnabled) { + // Modo legacy: verificar visibilidad en PHP + if (!UserVisibilityHelper::shouldShowForUser($settings)) { + return ''; + } + } + + // Si JS-First esta habilitado, renderizar siempre el slot + // La visibilidad se controlara via JavaScript + return $this->buildSlotHtml($slotType, $settings, $jsFirstEnabled); +} + +private function buildSlotHtml(string $slotType, array $settings, bool $jsFirstMode = false): string +{ + $classes = ['roi-ad-slot', 'roi-ad-slot--' . esc_attr($slotType)]; + + // Si JS-First esta activo, agregar clase para reservar espacio + if ($jsFirstMode) { + $classes[] = 'roi-ad-reserved'; + } + + // IMPORTANTE: El HTML de AdSense debe pasar por wp_kses_post para prevenir XSS + // si el contenido viene de la BD. El metodo getAdUnitHtml() debe retornar + // HTML ya escapado o se aplica wp_kses_post aqui como defensa en profundidad. + $adUnitHtml = $this->getAdUnitHtml($slotType, $settings); + + return sprintf( + '
%s
', + esc_attr(implode(' ', $classes)), + esc_attr($slotType), + wp_kses_post($adUnitHtml) // Escaping para prevenir XSS + ); +} + +/** + * Genera el HTML del ad unit de AdSense. + * + * @param string $slotType Tipo de slot (header, sidebar, etc.) + * @param array $settings Configuracion del componente + * @return string HTML del ad unit (debe ser escapado por el caller o internamente) + */ +private function getAdUnitHtml(string $slotType, array $settings): string +{ + // El ad_client y ad_slot vienen de la BD y deben ser sanitizados + $adClient = sanitize_text_field($settings['ad_codes']['ad_client'] ?? ''); + $adSlot = sanitize_text_field($settings['ad_codes']['ad_slot_' . $slotType] ?? ''); + + if (empty($adClient) || empty($adSlot)) { + return ''; + } + + // Generar HTML de AdSense con atributos escapados + return sprintf( + '', + esc_attr($adClient), + esc_attr($adSlot) + ); +} +``` + +### Enqueue de Assets + +Agregar metodo en el Renderer o crear clase separada: + +```php +settingsRepository->getComponentSettings('adsense-placement'); + + // Solo encolar si el componente esta habilitado + if (!($settings['visibility']['is_enabled'] ?? false)) { + return; + } + + $jsFirstEnabled = $settings['behavior']['javascript_first_mode'] ?? false; + + // Encolar CSS siempre (necesario para estados de slots) + wp_enqueue_style( + self::STYLE_HANDLE, + $this->getAssetUrl('adsense-controller.css'), + [], + self::SCRIPT_VERSION + ); + + // Encolar JS + wp_enqueue_script( + self::SCRIPT_HANDLE, + $this->getAssetUrl('adsense-controller.js'), + [], + self::SCRIPT_VERSION, + true // En footer + ); + + // Pasar configuracion al JS + // settingsVersion se usa para invalidar cache de localStorage cuando cambian settings + $settingsVersion = $this->getSettingsVersion($settings); + + wp_localize_script(self::SCRIPT_HANDLE, 'roiAdsenseControllerConfig', [ + 'featureEnabled' => $jsFirstEnabled, + 'endpoint' => rest_url('roi-theme/v1/adsense-placement/visibility'), + 'postId' => $this->getCurrentPostId(), + 'nonce' => wp_create_nonce('roi_adsense_visibility'), + 'timeout' => 3000, + 'debug' => defined('WP_DEBUG') && WP_DEBUG, + 'settingsVersion' => $settingsVersion, // Para invalidar cache cliente + ]); + } + + /** + * Genera un hash de version basado en settings relevantes para visibilidad. + * Cuando estos settings cambian, el cache de localStorage se invalida. + * + * @param array $settings + */ + private function getSettingsVersion(array $settings): int + { + $relevantSettings = [ + $settings['visibility']['hide_for_logged_users'] ?? false, + $settings['visibility']['excluded_roles'] ?? [], + $settings['forms']['exclude_post_ids'] ?? '', + ]; + + // Generar hash numerico de los settings relevantes + return crc32(serialize($relevantSettings)); + } + + private function getAssetUrl(string $filename): string + { + return get_template_directory_uri() + . '/Public/AdsensePlacement/Infrastructure/Ui/Assets/' + . $filename; + } + + private function getCurrentPostId(): int + { + if (is_singular()) { + return (int) get_the_ID(); + } + return 0; + } +} +``` + +### Integracion con adsense-loader.js Existente + +El script `adsense-loader.js` existente debe escuchar el evento de activacion: + +```javascript +// Agregar al final de adsense-loader.js existente: + +/** + * Listener para evento de activacion desde adsense-controller.js. + * Este evento se dispara cuando el controlador JS-First decide mostrar anuncios. + */ +window.addEventListener('roi-adsense-activate', function() { + // El IntersectionObserver ya esta configurado + // Solo necesitamos asegurar que los slots se procesen + + if (typeof window.roiAdsenseLoader !== 'undefined') { + window.roiAdsenseLoader.processSlots(); + } else { + // Fallback: re-observar slots + document.querySelectorAll('.roi-ad-slot[data-ad-lazy]').forEach(function(slot) { + if (!slot.dataset.adProcessed) { + // Disparar carga del anuncio + slot.dataset.adProcessed = 'pending'; + // La logica de carga existente se encargara + } + }); + } +}); +``` + +--- + +## Tests + +### Tests Unitarios + +```php +createMock(ComponentSettingsRepositoryInterface::class); + $repository->method('getComponentSettings') + ->with('adsense-placement') + ->willReturn($settings); + + return new AdsenseVisibilityChecker($repository); + } + + private function defaultSettings(array $overrides = []): array + { + return array_merge_recursive([ + 'visibility' => [ + 'is_enabled' => true, + 'hide_for_logged_users' => false, + 'excluded_roles' => [], + ], + 'forms' => [ + 'exclude_post_ids' => '', + ], + 'behavior' => [ + 'javascript_first_mode' => true, + 'visibility_cache_logged_seconds' => 300, + 'visibility_cache_anonymous_seconds' => 60, + ], + ], $overrides); + } + + public function testComponentDisabledReturnsFalseWithLongCache(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'visibility' => ['is_enabled' => false], + ])); + + $result = $checker->check(123, UserContext::anonymous()); + + $this->assertFalse($result->shouldShowAds()); + $this->assertContains('component_disabled', $result->getReasons()); + $this->assertEquals(3600, $result->getCacheSeconds()); + } + + public function testLoggedInUserExcludedReturnsFalse(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'visibility' => ['hide_for_logged_users' => true], + ])); + + $userContext = new UserContext(true, ['subscriber'], 1); + $result = $checker->check(123, $userContext); + + $this->assertFalse($result->shouldShowAds()); + $this->assertContains('logged_in_excluded', $result->getReasons()); + } + + public function testRoleExcludedReturnsFalse(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'visibility' => ['excluded_roles' => ['administrator', 'editor']], + ])); + + $userContext = new UserContext(true, ['administrator'], 1); + $result = $checker->check(123, $userContext); + + $this->assertFalse($result->shouldShowAds()); + $this->assertContains('role_excluded', $result->getReasons()); + } + + public function testPostExcludedReturnsFalse(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'forms' => ['exclude_post_ids' => '100, 123, 456'], + ])); + + $result = $checker->check(123, UserContext::anonymous()); + + $this->assertFalse($result->shouldShowAds()); + $this->assertContains('post_excluded', $result->getReasons()); + } + + public function testAnonymousUserWithNoExclusionsReturnsTrue(): void + { + $checker = $this->createChecker($this->defaultSettings()); + + $result = $checker->check(123, UserContext::anonymous()); + + $this->assertTrue($result->shouldShowAds()); + $this->assertEmpty($result->getReasons()); + } + + public function testCacheSecondsForLoggedInUser(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'behavior' => ['visibility_cache_logged_seconds' => 500], + ])); + + $userContext = new UserContext(true, ['subscriber'], 1); + $result = $checker->check(123, $userContext); + + $this->assertEquals(500, $result->getCacheSeconds()); + } + + public function testCacheSecondsForAnonymousUser(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'behavior' => ['visibility_cache_anonymous_seconds' => 120], + ])); + + $result = $checker->check(123, UserContext::anonymous()); + + $this->assertEquals(120, $result->getCacheSeconds()); + } + + public function testMultipleReasonsAccumulate(): void + { + $checker = $this->createChecker($this->defaultSettings([ + 'visibility' => [ + 'hide_for_logged_users' => true, + 'excluded_roles' => ['administrator'], + ], + ])); + + $userContext = new UserContext(true, ['administrator'], 1); + $result = $checker->check(123, $userContext); + + $this->assertFalse($result->shouldShowAds()); + $this->assertContains('logged_in_excluded', $result->getReasons()); + $this->assertContains('role_excluded', $result->getReasons()); + } +} +``` + +### Tests de Value Objects + +```php +assertFalse($context->isLoggedIn()); + $this->assertEmpty($context->getRoles()); + $this->assertEquals(0, $context->getUserId()); + } + + public function testHasAnyRoleReturnsTrueWhenMatches(): void + { + $context = new UserContext(true, ['editor', 'author'], 5); + + $this->assertTrue($context->hasAnyRole(['administrator', 'editor'])); + } + + public function testHasAnyRoleReturnsFalseWhenNoMatch(): void + { + $context = new UserContext(true, ['subscriber'], 5); + + $this->assertFalse($context->hasAnyRole(['administrator', 'editor'])); + } +} + +final class VisibilityDecisionTest extends TestCase +{ + public function testToArrayIncludesAllFields(): void + { + $decision = new VisibilityDecision(true, ['reason1'], 300); + $timestamp = 1733900000; + + $array = $decision->toArray($timestamp); + + $this->assertEquals([ + 'show_ads' => true, + 'reasons' => ['reason1'], + 'cache_seconds' => 300, + 'timestamp' => 1733900000, + ], $array); + } +} + +final class AdsenseSettingsTest extends TestCase +{ + public function testFromArrayParsesCorrectly(): void + { + $data = [ + 'visibility' => [ + 'is_enabled' => true, + 'hide_for_logged_users' => true, + 'excluded_roles' => ['administrator'], + ], + 'forms' => [ + 'exclude_post_ids' => '100, 200, 300', + ], + 'behavior' => [ + 'javascript_first_mode' => true, + ], + ]; + + $settings = AdsenseSettings::fromArray($data); + + $this->assertTrue($settings->isEnabled()); + $this->assertTrue($settings->shouldHideForLoggedUsers()); + $this->assertEquals(['administrator'], $settings->getExcludedRoles()); + $this->assertEquals([100, 200, 300], $settings->getExcludedPostIds()); + $this->assertTrue($settings->isJavascriptFirstMode()); + } + + public function testIsPostExcluded(): void + { + $settings = AdsenseSettings::fromArray([ + 'visibility' => ['is_enabled' => true], + 'forms' => ['exclude_post_ids' => '100, 200'], + 'behavior' => [], + ]); + + $this->assertTrue($settings->isPostExcluded(100)); + $this->assertTrue($settings->isPostExcluded(200)); + $this->assertFalse($settings->isPostExcluded(300)); + } +} +``` + +### Tests E2E (Cypress o similar) + +```javascript +// cypress/e2e/adsense-javascript-first.cy.js + +describe('AdSense JavaScript-First Architecture', () => { + + beforeEach(() => { + // Limpiar localStorage + cy.clearLocalStorage(); + }); + + it('shows ads for anonymous user when component is enabled', () => { + cy.visit('/sample-post/'); + + // Verificar que el endpoint fue llamado + cy.intercept('GET', '**/roi-theme/v1/adsense-placement/visibility*').as('visibilityCheck'); + + cy.wait('@visibilityCheck').then((interception) => { + expect(interception.response.body.show_ads).to.be.true; + }); + + // Verificar que los slots tienen clase activa + cy.get('.roi-ad-slot').should('have.class', 'roi-ad-active'); + }); + + it('hides ads for excluded user role', () => { + // Login como administrator (asumiendo rol excluido) + cy.login('admin', 'password'); + cy.visit('/sample-post/'); + + cy.intercept('GET', '**/roi-theme/v1/adsense-placement/visibility*').as('visibilityCheck'); + + cy.wait('@visibilityCheck').then((interception) => { + expect(interception.response.body.show_ads).to.be.false; + expect(interception.response.body.reasons).to.include('role_excluded'); + }); + + // Verificar que los slots estan ocultos + cy.get('.roi-ad-slot').should('have.class', 'roi-ad-hidden'); + }); + + it('uses cached decision on subsequent visits', () => { + cy.visit('/sample-post/'); + + cy.intercept('GET', '**/roi-theme/v1/adsense-placement/visibility*').as('visibilityCheck'); + + // Primera visita - debe llamar al endpoint + cy.wait('@visibilityCheck'); + + // Recargar pagina + cy.reload(); + + // Segunda visita - NO debe llamar al endpoint (usa cache) + cy.get('.roi-ad-slot').should('have.class', 'roi-ad-active'); + cy.get('@visibilityCheck.all').should('have.length', 1); + }); + + it('handles timeout gracefully with fallback', () => { + cy.intercept('GET', '**/roi-theme/v1/adsense-placement/visibility*', { + delay: 5000, // Mas que el timeout de 3000ms + }).as('slowVisibilityCheck'); + + cy.visit('/sample-post/'); + + // Debe mostrar ads por fallback (cached-or-show) + cy.get('.roi-ad-slot', { timeout: 4000 }).should('have.class', 'roi-ad-active'); + }); + + it('reserves space to prevent CLS', () => { + cy.visit('/sample-post/'); + + // Inmediatamente despues de cargar, slots deben tener espacio reservado + cy.get('.roi-ad-slot').should('have.class', 'roi-ad-reserved'); + cy.get('.roi-ad-slot').should('have.css', 'min-height').and('not.eq', '0px'); + }); +}); +``` + +--- + +## Rollback Plan + +1. Cambiar feature flag `javascript_first_mode` a `false` en BD +2. Limpiar cache de paginas +3. El JS detecta `featureEnabled=false` y usa modo legacy +4. Monitorear metricas por 24h para confirmar normalizacion + +--- + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2025-12-11 | Initial spec basada en analisis de cache compatibility | +| 1.1 | 2025-12-11 | Mejoras SOLID: UserContext Value Object, timestamp inyectado, PHPDoc completo | +| 1.2 | 2025-12-11 | Completitud: DI Container, ServiceProvider, Bootstrap, Renderer mods, Asset Enqueue, JSON Schema, AdsenseSettings VO, Tests unitarios y E2E | +| 1.3 | 2025-12-11 | Correcciones criticas: Eliminar codigo duplicado AdsenseVisibilityChecker, definir ContainerInterface completo con SimpleContainer, agregar wp_kses_post() y sanitize_text_field() en Renderer, documentar JSON Schema completo con merge, agregar invalidacion de cache por version (settingsVersion), documentar codigos de error HTTP, consolidar bootstrap con helper roi_theme_get_container() | +| 1.4 | 2025-12-11 | Issues bloqueantes resueltos: (1) Agregar seccion Dependencies con ComponentSettingsRepositoryInterface y WPComponentSettingsRepository completos, (2) Corregir validacion postId en JS para aceptar 0 como valido, (3) Eliminar $GLOBALS usando closures encadenadas en bootstrap, (4) Agregar seccion Autoload PSR-4 con opciones Composer y manual | +| 1.5 | 2025-12-11 | Correccion final: (1) Validacion postId en Controller REST cambiada de `$value > 0` a `$value >= 0` para consistencia con JS, (2) Definir ContainerException con factory method bindingNotFound() referenciada en PHPDoc de ContainerInterface, (3) Actualizar SimpleContainer para usar ContainerException en lugar de RuntimeException |