Files
roi-theme/openspec/specs/adsense-javascript-first/spec.md
FrankZamora 8936670451 feat(config): add adsense-javascript-first spec v1.5
- 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 <noreply@anthropic.com>
2025-12-11 12:30:57 -06:00

79 KiB

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:
{
  "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
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;

/**
 * Value Object que encapsula el contexto del usuario actual.
 * Inmutable despues de construccion. No contiene logica de WordPress.
 */
final class UserContext
{
    /**
     * @param bool $isLoggedIn Indica si el usuario esta autenticado
     * @param array<string> $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<string>
     */
    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<string> $rolesToCheck Roles a verificar
     */
    public function hasAnyRole(array $rolesToCheck): bool
    {
        return count(array_intersect($this->roles, $rolesToCheck)) > 0;
    }
}

Archivo: VisibilityDecision.php

<?php
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;

/**
 * Value Object que encapsula la decision de visibilidad de anuncios.
 * Inmutable despues de construccion. No contiene logica de WordPress.
 */
final class VisibilityDecision
{
    /**
     * @param bool $showAds Indica si los anuncios deben mostrarse
     * @param array<string> $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<string>
     */
    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<string>, 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
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Domain\Contracts;

use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;

/**
 * Contrato para el servicio que evalua visibilidad de anuncios.
 * La implementacion concreta reside en Infrastructure.
 */
interface AdsenseVisibilityCheckerInterface
{
    /**
     * Evalua si los anuncios deben mostrarse para el contexto actual.
     *
     * @param int $postId ID del post donde se mostrarian los anuncios
     * @param UserContext $userContext Contexto del usuario actual
     * @return VisibilityDecision Decision con resultado, razones y tiempo de cache
     */
    public function check(int $postId, UserContext $userContext): VisibilityDecision;
}

Archivo: CheckAdsenseVisibilityUseCase.php

<?php
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Application\UseCases;

use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;

/**
 * Caso de uso para verificar visibilidad de anuncios.
 * Orquesta la llamada al servicio de dominio sin contener logica de negocio.
 */
final class CheckAdsenseVisibilityUseCase
{
    public function __construct(
        private AdsenseVisibilityCheckerInterface $visibilityChecker
    ) {}

    /**
     * Ejecuta la verificacion de visibilidad.
     *
     * @param int $postId ID del post
     * @param UserContext $userContext Contexto del usuario
     * @return VisibilityDecision Decision de visibilidad
     */
    public function execute(int $postId, UserContext $userContext): VisibilityDecision
    {
        return $this->visibilityChecker->check($postId, $userContext);
    }
}

Archivo: AdsenseVisibilityController.php

<?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;

/**
 * Controller REST para el endpoint de visibilidad de anuncios.
 * Toda la logica de WordPress (register_rest_route, WP_REST_*, wp_verify_nonce)
 * esta contenida en esta clase de Infrastructure.
 */
final class AdsenseVisibilityController
{
    private const NONCE_ACTION = 'roi_adsense_visibility';

    public function __construct(
        private CheckAdsenseVisibilityUseCase $useCase
    ) {}

    /**
     * Registra el hook de WordPress para inicializar rutas REST.
     */
    public function register(): void
    {
        add_action('rest_api_init', [$this, 'registerRoutes']);
    }

    /**
     * Registra la ruta REST del endpoint.
     */
    public function registerRoutes(): void
    {
        register_rest_route('roi-theme/v1', '/adsense-placement/visibility', [
            '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 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

/**
 * 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

/* 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
declare(strict_types=1);

namespace ROITheme\Shared\Domain\Contracts;

/**
 * Contrato para repositorio de configuracion de componentes.
 * La implementacion concreta accede a la tabla wp_roi_theme_component_settings.
 */
interface ComponentSettingsRepositoryInterface
{
    /**
     * Obtiene la configuracion completa de un componente.
     *
     * @param string $componentName Nombre del componente en kebab-case (ej: 'adsense-placement')
     * @return array<string, mixed> 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
declare(strict_types=1);

namespace ROITheme\Shared\Infrastructure\Persistence\WordPress;

use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;

/**
 * Implementacion WordPress del repositorio de settings.
 * Accede a la tabla wp_roi_theme_component_settings.
 */
final class WPComponentSettingsRepository implements ComponentSettingsRepositoryInterface
{
    private const TABLE_NAME = 'roi_theme_component_settings';

    /**
     * {@inheritdoc}
     */
    public function getComponentSettings(string $componentName): array
    {
        global $wpdb;

        $tableName = $wpdb->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
declare(strict_types=1);

namespace ROITheme\Shared\Infrastructure\Container;

use RuntimeException;

/**
 * Excepcion lanzada cuando el contenedor no puede resolver una dependencia.
 */
final class ContainerException extends RuntimeException
{
    /**
     * Crea excepcion para binding no encontrado.
     */
    public static function bindingNotFound(string $abstract): self
    {
        return new self("No binding found for: {$abstract}");
    }
}

Ubicacion: Shared/Infrastructure/Container/ContainerException.php


Archivo: ContainerInterface.php

El contenedor DI debe implementar esta interface para que el ServiceProvider funcione:

<?php
declare(strict_types=1);

namespace ROITheme\Shared\Infrastructure\Container;

/**
 * Interface para el contenedor de inyeccion de dependencias.
 * El tema debe proveer una implementacion concreta.
 */
interface ContainerInterface
{
    /**
     * Registra un binding en el contenedor.
     *
     * @param string $abstract El nombre de la clase/interface a registrar
     * @param callable $factory Factory que crea la instancia
     */
    public function bind(string $abstract, callable $factory): void;

    /**
     * Resuelve una dependencia del contenedor.
     *
     * @template T of object
     * @param class-string<T> $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
declare(strict_types=1);

namespace ROITheme\Shared\Infrastructure\Container;

/**
 * Implementacion simple de contenedor DI basada en array.
 */
final class SimpleContainer implements ContainerInterface
{
    /** @var array<string, callable> */
    private array $bindings = [];

    /** @var array<string, object> */
    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:

{
  "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:
    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
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;

/**
 * Value Object que encapsula la configuracion del componente AdSense.
 * Provee tipado fuerte y validacion en lugar de acceder a arrays raw.
 */
final class AdsenseSettings
{
    /**
     * @param bool $isEnabled Si el componente esta habilitado globalmente
     * @param bool $hideForLoggedUsers Si ocultar ads para usuarios logueados
     * @param array<string> $excludedRoles Roles excluidos de ver anuncios
     * @param array<int> $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<string, mixed> $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<string>
     */
    public function getExcludedRoles(): array
    {
        return $this->excludedRoles;
    }

    /**
     * @return array<int>
     */
    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<int>
     */
    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
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Infrastructure\Services;

use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\AdsenseSettings;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;

/**
 * Implementacion concreta del verificador de visibilidad de anuncios.
 * Usa AdsenseSettings Value Object para acceso tipado a configuracion.
 */
final class AdsenseVisibilityChecker implements AdsenseVisibilityCheckerInterface
{
    private const CACHE_DISABLED_SECONDS = 3600;

    public function __construct(
        private ComponentSettingsRepositoryInterface $settingsRepository
    ) {}

    /**
     * {@inheritdoc}
     */
    public function check(int $postId, UserContext $userContext): VisibilityDecision
    {
        $rawSettings = $this->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
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Infrastructure\Providers;

use ROITheme\Public\AdsensePlacement\Application\UseCases\CheckAdsenseVisibilityUseCase;
use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress\AdsenseVisibilityController;
use ROITheme\Public\AdsensePlacement\Infrastructure\Services\AdsenseVisibilityChecker;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Infrastructure\Container\ContainerInterface;

/**
 * Service Provider para registrar las dependencias del sistema JavaScript-First.
 * Se invoca desde functions.php o el bootstrap del tema.
 */
final class AdsenseJavascriptFirstServiceProvider
{
    public function __construct(
        private ContainerInterface $container
    ) {}

    /**
     * Registra todas las dependencias en el contenedor.
     */
    public function register(): void
    {
        // Registrar Service que implementa la interface
        $this->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
// En functions.php o archivo de bootstrap del tema

use ROITheme\Public\AdsensePlacement\Infrastructure\Providers\AdsenseJavascriptFirstServiceProvider;
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsenseAssetsEnqueuer;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Infrastructure\Container\ContainerInterface;

/**
 * Bootstrap del sistema JavaScript-First para AdSense.
 * IMPORTANTE: Este codigo REEMPLAZA cualquier inicializacion previa del componente.
 *
 * Usa closures encadenadas para evitar $GLOBALS y mantener estado entre hooks.
 */
add_action('after_setup_theme', function(): void {
    /** @var ContainerInterface $container */
    $container = roi_theme_get_container();

    // Registrar bindings en el contenedor
    $provider = new AdsenseJavascriptFirstServiceProvider($container);
    $provider->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:

{
    "name": "roi-theme/roi-theme",
    "autoload": {
        "psr-4": {
            "ROITheme\\": "./"
        }
    }
}

Luego ejecutar:

cd wp-content/themes/roi-theme
composer dump-autoload

Y en functions.php al inicio:

<?php
require_once __DIR__ . '/vendor/autoload.php';

Opcion B: Autoload Manual (Sin Composer)

Si el tema NO usa Composer, agregar autoloader manual en functions.php:

<?php
/**
 * Autoloader PSR-4 para ROITheme.
 * Agregar al INICIO de functions.php, antes de cualquier use statement.
 */
spl_autoload_register(function (string $class): void {
    // Solo manejar clases del namespace ROITheme
    $prefix = 'ROITheme\\';
    $prefixLength = strlen($prefix);

    if (strncmp($prefix, $class, $prefixLength) !== 0) {
        return;
    }

    // Obtener path relativo de la clase
    $relativeClass = substr($class, $prefixLength);

    // Convertir namespace a path de archivo
    // ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext
    // -> 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
// ANTES (codigo legacy):
public function renderSlot(string $slotType, array $settings): string
{
    // Verificacion PHP-side que se "congela" en cache
    if (!UserVisibilityHelper::shouldShowForUser($settings)) {
        return '';
    }

    return $this->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(
        '<div class="%s" data-ad-lazy="true" data-slot-type="%s">%s</div>',
        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<string, mixed> $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(
        '<ins class="adsbygoogle" style="display:block" data-ad-client="%s" data-ad-slot="%s" data-ad-format="auto" data-full-width-responsive="true"></ins>',
        esc_attr($adClient),
        esc_attr($adSlot)
    );
}

Enqueue de Assets

Agregar metodo en el Renderer o crear clase separada:

<?php
declare(strict_types=1);

namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;

use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;

/**
 * Encola los assets necesarios para el sistema JavaScript-First.
 */
final class AdsenseAssetsEnqueuer
{
    private const SCRIPT_HANDLE = 'roi-adsense-controller';
    private const STYLE_HANDLE = 'roi-adsense-controller-css';
    private const SCRIPT_VERSION = '1.1.0';

    public function __construct(
        private ComponentSettingsRepositoryInterface $settingsRepository
    ) {}

    /**
     * Registra el hook para encolar assets.
     */
    public function register(): void
    {
        add_action('wp_enqueue_scripts', [$this, 'enqueue']);
    }

    /**
     * Encola JS y CSS si el feature flag esta activo.
     */
    public function enqueue(): void
    {
        $settings = $this->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<string, mixed> $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:

// 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
declare(strict_types=1);

namespace ROITheme\Tests\Unit\Public\AdsensePlacement;

use PHPUnit\Framework\TestCase;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\AdsenseSettings;
use ROITheme\Public\AdsensePlacement\Infrastructure\Services\AdsenseVisibilityChecker;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;

final class AdsenseVisibilityCheckerTest extends TestCase
{
    private function createChecker(array $settings): AdsenseVisibilityChecker
    {
        $repository = $this->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
declare(strict_types=1);

namespace ROITheme\Tests\Unit\Public\AdsensePlacement\Domain\ValueObjects;

use PHPUnit\Framework\TestCase;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\AdsenseSettings;

final class UserContextTest extends TestCase
{
    public function testAnonymousFactoryMethod(): void
    {
        $context = UserContext::anonymous();

        $this->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)

// 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