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

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

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

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

View File

@@ -0,0 +1,36 @@
<?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;
/**
* Use Case para verificar visibilidad de anuncios AdSense.
*
* Application Layer - Orquesta el checker de domain.
* No contiene logica de negocio, solo coordina.
*
* @package ROITheme\Public\AdsensePlacement\Application\UseCases
*/
final class CheckAdsenseVisibilityUseCase
{
public function __construct(
private AdsenseVisibilityCheckerInterface $visibilityChecker
) {
}
/**
* Ejecuta la verificacion de visibilidad.
*
* @param int $postId ID del post (0 para paginas de archivo/home)
* @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);
}
}

View File

@@ -0,0 +1,26 @@
<?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;
/**
* Interface para el servicio que evalua visibilidad de anuncios.
*
* Domain Layer - Define el contrato sin dependencias de infraestructura.
*
* @package ROITheme\Public\AdsensePlacement\Domain\Contracts
*/
interface AdsenseVisibilityCheckerInterface
{
/**
* Evalua si los anuncios deben mostrarse para el contexto dado.
*
* @param int $postId ID del post (0 para paginas de archivo/home)
* @param UserContext $userContext Contexto del usuario
* @return VisibilityDecision Decision con razones y cache
*/
public function check(int $postId, UserContext $userContext): VisibilityDecision;
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que encapsula la configuracion de AdSense relevante para visibilidad.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class AdsenseSettings
{
/**
* @param bool $isEnabled Si AdSense esta activo globalmente
* @param bool $showOnDesktop Si se muestra en desktop
* @param bool $showOnMobile Si se muestra en mobile
* @param bool $hideForLoggedIn Si se oculta para usuarios logueados
* @param bool $javascriptFirstMode Si el modo JS-first esta activo
* @param array<int> $excludedCategoryIds IDs de categorias excluidas
* @param array<int> $excludedPostIds IDs de posts excluidos
* @param array<string> $excludedPostTypes Post types excluidos
*/
public function __construct(
private bool $isEnabled,
private bool $showOnDesktop,
private bool $showOnMobile,
private bool $hideForLoggedIn,
private bool $javascriptFirstMode,
private array $excludedCategoryIds = [],
private array $excludedPostIds = [],
private array $excludedPostTypes = []
) {
}
public function isEnabled(): bool
{
return $this->isEnabled;
}
public function showOnDesktop(): bool
{
return $this->showOnDesktop;
}
public function showOnMobile(): bool
{
return $this->showOnMobile;
}
public function hideForLoggedIn(): bool
{
return $this->hideForLoggedIn;
}
public function isJavascriptFirstMode(): bool
{
return $this->javascriptFirstMode;
}
/**
* @return array<int>
*/
public function getExcludedCategoryIds(): array
{
return $this->excludedCategoryIds;
}
/**
* @return array<int>
*/
public function getExcludedPostIds(): array
{
return $this->excludedPostIds;
}
/**
* @return array<string>
*/
public function getExcludedPostTypes(): array
{
return $this->excludedPostTypes;
}
public function isPostExcluded(int $postId): bool
{
return in_array($postId, $this->excludedPostIds, true);
}
public function isPostTypeExcluded(string $postType): bool
{
return in_array($postType, $this->excludedPostTypes, true);
}
public function isCategoryExcluded(int $categoryId): bool
{
return in_array($categoryId, $this->excludedCategoryIds, true);
}
/**
* Crea instancia desde array de configuracion de BD.
*
* @param array<string, array<string, mixed>> $settings Configuracion agrupada
*/
public static function fromArray(array $settings): self
{
$visibility = $settings['visibility'] ?? [];
$behavior = $settings['behavior'] ?? [];
$forms = $settings['forms'] ?? [];
return new self(
isEnabled: (bool) ($visibility['is_enabled'] ?? false),
showOnDesktop: (bool) ($visibility['show_on_desktop'] ?? true),
showOnMobile: (bool) ($visibility['show_on_mobile'] ?? true),
hideForLoggedIn: (bool) ($visibility['hide_for_logged_in'] ?? false),
javascriptFirstMode: (bool) ($behavior['javascript_first_mode'] ?? false),
excludedCategoryIds: self::parseIds($forms['exclude_categories'] ?? ''),
excludedPostIds: self::parseIds($forms['exclude_post_ids'] ?? ''),
excludedPostTypes: self::parsePostTypes($forms['exclude_post_types'] ?? '')
);
}
/**
* Parsea string de IDs separados por coma a array de enteros.
*
* @return array<int>
*/
private static function parseIds(string $value): array
{
if (empty($value)) {
return [];
}
return array_filter(
array_map(
static fn(string $id): int => (int) trim($id),
explode(',', $value)
),
static fn(int $id): bool => $id > 0
);
}
/**
* Parsea string de post types separados por coma.
*
* @return array<string>
*/
private static function parsePostTypes(string $value): array
{
if (empty($value)) {
return [];
}
return array_filter(
array_map(
static fn(string $type): string => trim($type),
explode(',', $value)
),
static fn(string $type): bool => $type !== ''
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que encapsula el contexto del usuario para decisiones de visibilidad.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class UserContext
{
/**
* @param bool $isLoggedIn Si el usuario tiene sesion activa
* @param bool $isMobile Si el dispositivo es movil (viewport < 992px)
* @param array<int> $userRoles IDs de roles del usuario
*/
public function __construct(
private bool $isLoggedIn,
private bool $isMobile,
private array $userRoles = []
) {
}
public function isLoggedIn(): bool
{
return $this->isLoggedIn;
}
public function isMobile(): bool
{
return $this->isMobile;
}
public function isDesktop(): bool
{
return !$this->isMobile;
}
/**
* @return array<int>
*/
public function getUserRoles(): array
{
return $this->userRoles;
}
public function hasRole(int $roleId): bool
{
return in_array($roleId, $this->userRoles, true);
}
/**
* Crea instancia desde array (para deserializacion).
*
* @param array{is_logged_in: bool, is_mobile: bool, user_roles?: array<int>} $data
*/
public static function fromArray(array $data): self
{
return new self(
isLoggedIn: $data['is_logged_in'] ?? false,
isMobile: $data['is_mobile'] ?? false,
userRoles: $data['user_roles'] ?? []
);
}
/**
* Serializa a array para respuesta JSON.
*
* @return array{is_logged_in: bool, is_mobile: bool, user_roles: array<int>}
*/
public function toArray(): array
{
return [
'is_logged_in' => $this->isLoggedIn,
'is_mobile' => $this->isMobile,
'user_roles' => $this->userRoles,
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que representa la decision de mostrar o no anuncios.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
* El timestamp se inyecta en toArray() para mantener Domain puro.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class VisibilityDecision
{
private const DEFAULT_CACHE_SECONDS = 300; // 5 minutos
/**
* @param bool $showAds Si se deben mostrar los anuncios
* @param array<string> $reasons Razones de la decision (para debugging)
* @param int $cacheSeconds Tiempo de cache en segundos
*/
public function __construct(
private bool $showAds,
private array $reasons = [],
private int $cacheSeconds = self::DEFAULT_CACHE_SECONDS
) {
}
public function shouldShowAds(): bool
{
return $this->showAds;
}
/**
* @return array<string>
*/
public function getReasons(): array
{
return $this->reasons;
}
public function getCacheSeconds(): int
{
return $this->cacheSeconds;
}
/**
* Factory: Crea decision positiva (mostrar ads).
*/
public static function show(int $cacheSeconds = self::DEFAULT_CACHE_SECONDS): self
{
return new self(
showAds: true,
reasons: ['all_conditions_passed'],
cacheSeconds: $cacheSeconds
);
}
/**
* Factory: Crea decision negativa (ocultar ads).
*
* @param array<string> $reasons
*/
public static function hide(array $reasons, int $cacheSeconds = self::DEFAULT_CACHE_SECONDS): self
{
return new self(
showAds: false,
reasons: $reasons,
cacheSeconds: $cacheSeconds
);
}
/**
* Serializa a array para respuesta JSON.
*
* El timestamp se inyecta aqui (no en constructor) para mantener Domain puro.
*
* @param int $timestamp Unix timestamp actual
* @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,
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"component_name": "adsense-placement",
"version": "1.5.0",
"version": "1.6.0",
"description": "Control de AdSense y Google Analytics - Con In-Content Ads Avanzado",
"groups": {
"visibility": {
@@ -282,6 +282,13 @@
"label": "Ubicaciones en Posts",
"priority": 70,
"fields": {
"javascript_first_mode": {
"type": "boolean",
"label": "JavaScript-First Mode (Cache Compatible)",
"default": false,
"editable": true,
"description": "Mueve decisiones de visibilidad de PHP a JavaScript para compatibilidad con cache de pagina. Permite personalizacion por usuario en sitios cacheados."
},
"post_top_enabled": {
"type": "boolean",
"label": "Despues de Featured Image",

View File

@@ -174,6 +174,15 @@ try {
);
$youtubeFacadeHooksRegistrar->register();
// === ADSENSE JAVASCRIPT-FIRST MODE (Plan adsense-javascript-first) ===
// Mueve decisiones de visibilidad de PHP a JS para compatibilidad con cache
// @see openspec/specs/adsense-javascript-first/spec.md
$adsenseJsFirstProvider = new \ROITheme\Public\AdsensePlacement\Infrastructure\Providers\AdsenseJavascriptFirstServiceProvider(
$container
);
$adsenseJsFirstProvider->register();
$adsenseJsFirstProvider->boot();
// === CACHE-FIRST ARCHITECTURE (Plan 1000.01) ===
// Hook para plugins externos que necesitan evaluar acceso antes de servir página
// @see openspec/specs/cache-first-architecture/spec.md

View File

@@ -0,0 +1,545 @@
# Plan de Pruebas: AdSense JavaScript-First Architecture
> **NOTA IMPORTANTE - PROTOCOLO DE PRUEBAS**
>
> Las pruebas se ejecutan en el servidor de PRODUCCION.
> Si hay algo que corregir, se modifica en LOCAL y luego se despliega.
>
> **PROHIBIDO**: Modificar codigo directamente en produccion.
> **PERMITIDO**: Solo ejecutar pruebas y verificaciones en produccion.
>
> Flujo correcto:
> 1. Ejecutar prueba en produccion
> 2. Si falla, corregir en local
> 3. Desplegar cambios a produccion
> 4. Re-ejecutar prueba
---
## Resumen de Pruebas
| ID | Categoria | Descripcion | Criterio de Aceptacion |
|----|-----------|-------------|------------------------|
| T01 | Endpoint REST | Endpoint registrado y accesible | HTTP 200 con JSON valido |
| T02 | Endpoint REST | Headers anti-cache presentes | Cache-Control, Pragma, Expires |
| T03 | Endpoint REST | Parametro post_id requerido | HTTP 400 sin post_id |
| T04 | Endpoint REST | post_id=0 valido (archivos/home) | HTTP 200 con post_id=0 |
| T05 | Visibilidad | Componente deshabilitado | show_ads=false, reason=component_disabled |
| T06 | Visibilidad | Usuario anonimo sin exclusiones | show_ads=true, reasons=[] |
| T07 | Visibilidad | Usuario logueado excluido | show_ads=false, reason=logged_in_excluded |
| T08 | Visibilidad | Rol excluido | show_ads=false, reason=role_excluded |
| T09 | Visibilidad | Post excluido | show_ads=false, reason=post_excluded |
| T10 | JavaScript | Script cargado en frontend | roiAdsenseConfig definido |
| T11 | JavaScript | Cache localStorage funciona | Datos guardados correctamente |
| T12 | JavaScript | Fallback cuando error | Ads se muestran en error |
| T13 | Feature Flag | Modo deshabilitado = legacy | No llama endpoint |
| T14 | Feature Flag | Modo habilitado = JS-First | Llama endpoint |
| T15 | Clean Arch | Value Objects inmutables | No WordPress en Domain |
| T16 | Clean Arch | Interface en Domain | AdsenseVisibilityCheckerInterface existe |
---
## Pruebas Detalladas
### T01: Endpoint REST Registrado y Accesible
**Categoria**: Endpoint REST
**Prioridad**: CRITICA
**Spec Reference**: Requirement: Endpoint REST Visibility
**Pasos**:
1. Abrir navegador o usar curl
2. Acceder a: `https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=1`
**Resultado Esperado**:
- HTTP Status: 200
- Content-Type: application/json
- Body contiene: `show_ads`, `reasons`, `cache_seconds`, `timestamp`
**Comando de Prueba**:
```bash
curl -i "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=1"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T02: Headers Anti-Cache Presentes
**Categoria**: Endpoint REST
**Prioridad**: ALTA
**Spec Reference**: Scenario: Headers anti-cache obligatorios
**Pasos**:
1. Hacer request al endpoint
2. Verificar headers de respuesta
**Resultado Esperado**:
- `Cache-Control: no-store, no-cache, must-revalidate, max-age=0`
- `Pragma: no-cache`
- `Expires: Thu, 01 Jan 1970 00:00:00 GMT` o `0`
- `Vary: Cookie`
**Comando de Prueba**:
```bash
curl -I "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=1"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T03: Parametro post_id Requerido
**Categoria**: Endpoint REST
**Prioridad**: ALTA
**Spec Reference**: Scenario: Parametros del endpoint
**Pasos**:
1. Hacer request SIN post_id
2. Verificar respuesta de error
**Resultado Esperado**:
- HTTP Status: 400 (Bad Request)
- Body contiene mensaje de error indicando que post_id es requerido
**Comando de Prueba**:
```bash
curl -i "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T04: post_id=0 Valido para Paginas de Archivo
**Categoria**: Endpoint REST
**Prioridad**: ALTA
**Spec Reference**: Scenario: Parametros del endpoint (validate_callback >= 0)
**Pasos**:
1. Hacer request con post_id=0
2. Verificar que responde correctamente
**Resultado Esperado**:
- HTTP Status: 200
- Body contiene decision de visibilidad valida
**Comando de Prueba**:
```bash
curl -i "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=0"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T05: Componente Deshabilitado Retorna False
**Categoria**: Visibilidad
**Prioridad**: ALTA
**Spec Reference**: Scenario: Componente deshabilitado
**Pre-condicion**:
- Deshabilitar componente en admin (is_enabled = false)
**Pasos**:
1. Deshabilitar adsense-placement en admin
2. Hacer request al endpoint
3. Verificar respuesta
**Resultado Esperado**:
```json
{
"show_ads": false,
"reasons": ["component_disabled"],
"cache_seconds": 3600
}
```
**Comando de Prueba**:
```bash
curl "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=1"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T06: Usuario Anonimo Sin Exclusiones Ve Ads
**Categoria**: Visibilidad
**Prioridad**: CRITICA
**Spec Reference**: Scenario: Usuario anonimo sin exclusiones
**Pre-condicion**:
- Componente habilitado
- javascript_first_mode habilitado
- Sin exclusiones configuradas
- No estar logueado
**Pasos**:
1. Abrir navegador en modo incognito
2. Acceder a un post del sitio
3. Verificar respuesta del endpoint
**Resultado Esperado**:
```json
{
"show_ads": true,
"reasons": [],
"cache_seconds": 60
}
```
**Comando de Prueba**:
```bash
curl "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=1"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T07: Usuario Logueado Excluido
**Categoria**: Visibilidad
**Prioridad**: ALTA
**Spec Reference**: Scenario: Usuario logueado excluido
**Pre-condicion**:
- Activar "Ocultar para usuarios logueados" en admin
**Pasos**:
1. Loguearse en WordPress
2. Copiar cookies de sesion
3. Hacer request con cookies
**Resultado Esperado**:
```json
{
"show_ads": false,
"reasons": ["logged_in_excluded"],
"cache_seconds": 300
}
```
**Verificacion Manual**:
1. Loguearse en wp-admin
2. Visitar un post en el frontend
3. Abrir DevTools > Network
4. Buscar request a `/visibility`
5. Verificar respuesta
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T08: Rol Excluido
**Categoria**: Visibilidad
**Prioridad**: ALTA
**Spec Reference**: Scenario: Rol de usuario excluido
**Pre-condicion**:
- Agregar "administrator" a roles excluidos en admin
**Pasos**:
1. Loguearse como administrator
2. Visitar un post
3. Verificar respuesta del endpoint
**Resultado Esperado**:
```json
{
"show_ads": false,
"reasons": ["role_excluded"],
"cache_seconds": 300
}
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T09: Post Excluido por ID
**Categoria**: Visibilidad
**Prioridad**: ALTA
**Spec Reference**: Scenario: Post excluido por ID
**Pre-condicion**:
- Agregar un ID de post a "IDs de posts excluidos" en admin
**Pasos**:
1. Anotar el ID del post excluido (ej: 123)
2. Hacer request con ese post_id
3. Verificar respuesta
**Resultado Esperado**:
```json
{
"show_ads": false,
"reasons": ["post_excluded"],
"cache_seconds": 60
}
```
**Comando de Prueba**:
```bash
curl "https://analisisdepreciosunitarios.com/wp-json/roi-theme/v1/adsense-placement/visibility?post_id=123"
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T10: Script JavaScript Cargado
**Categoria**: JavaScript
**Prioridad**: CRITICA
**Spec Reference**: Scenario: Configuracion via wp_localize_script
**Pre-condicion**:
- javascript_first_mode habilitado
**Pasos**:
1. Visitar un post en el frontend
2. Abrir DevTools > Console
3. Escribir: `window.roiAdsenseConfig`
**Resultado Esperado**:
- Objeto definido con propiedades:
- `endpoint`: URL del endpoint REST
- `postId`: ID del post actual
- `nonce`: String no vacio
- `featureEnabled`: true
- `debug`: boolean
**Verificacion Alternativa**:
```javascript
// En consola del navegador
console.log(window.roiAdsenseConfig);
console.log(typeof window.roiAdsenseVisibility);
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T11: Cache localStorage Funciona
**Categoria**: JavaScript
**Prioridad**: ALTA
**Spec Reference**: Scenario: Cache en localStorage
**Pasos**:
1. Visitar un post (primera vez)
2. Abrir DevTools > Application > Local Storage
3. Buscar key `roi_adsense_visibility`
4. Recargar pagina
5. Verificar en Network que NO hay nueva llamada al endpoint
**Resultado Esperado**:
- localStorage contiene:
```json
{
"show_ads": true,
"reasons": [],
"timestamp": 1733900000,
"cache_seconds": 60
}
```
- Segunda carga NO hace request al endpoint (usa cache)
**Verificacion en Consola**:
```javascript
localStorage.getItem('roi_adsense_visibility');
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T12: Fallback en Error de Red
**Categoria**: JavaScript
**Prioridad**: ALTA
**Spec Reference**: Scenario: Fallback strategy cached-or-show
**Pasos**:
1. Limpiar localStorage
2. Abrir DevTools > Network
3. Habilitar "Offline" mode
4. Visitar un post
5. Verificar comportamiento
**Resultado Esperado**:
- Los ads se muestran (fallback = show)
- No hay error en consola (error manejado gracefully)
**Verificacion Alternativa**:
```javascript
// Limpiar cache
window.roiAdsenseVisibility.clearCache();
// Recargar con network offline
```
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T13: Feature Flag Deshabilitado = Modo Legacy
**Categoria**: Feature Flag
**Prioridad**: ALTA
**Spec Reference**: Scenario: Feature flag deshabilitado
**Pre-condicion**:
- Deshabilitar javascript_first_mode en admin
**Pasos**:
1. Deshabilitar javascript_first_mode
2. Visitar un post
3. Verificar en Network que NO hay llamada al endpoint
**Resultado Esperado**:
- `roiAdsenseConfig.featureEnabled` = false
- No hay request a `/visibility` endpoint
- Ads se muestran inmediatamente (modo legacy)
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T14: Feature Flag Habilitado = JS-First
**Categoria**: Feature Flag
**Prioridad**: ALTA
**Spec Reference**: Scenario: Feature flag habilitado
**Pre-condicion**:
- Habilitar javascript_first_mode en admin
**Pasos**:
1. Habilitar javascript_first_mode
2. Limpiar cache (localStorage y pagina)
3. Visitar un post
4. Verificar en Network que SI hay llamada al endpoint
**Resultado Esperado**:
- `roiAdsenseConfig.featureEnabled` = true
- Request a `/visibility` endpoint presente
- Ads se muestran/ocultan segun respuesta
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T15: Value Objects Sin Dependencias WordPress
**Categoria**: Clean Architecture
**Prioridad**: MEDIA
**Spec Reference**: Scenario: Value Object VisibilityDecision en Domain
**Verificacion**:
Revisar que los archivos NO contengan funciones de WordPress:
**Archivos a verificar**:
- `Domain/ValueObjects/UserContext.php`
- `Domain/ValueObjects/VisibilityDecision.php`
- `Domain/ValueObjects/AdsenseSettings.php`
- `Domain/Contracts/AdsenseVisibilityCheckerInterface.php`
- `Application/UseCases/CheckAdsenseVisibilityUseCase.php`
**Resultado Esperado**:
- Sin `get_`, `wp_`, `is_user_logged_in`, `WP_*` classes
- Solo PHP puro y tipos del proyecto
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
### T16: Interface en Domain
**Categoria**: Clean Architecture
**Prioridad**: MEDIA
**Spec Reference**: Scenario: Interface en Domain
**Verificacion**:
El archivo `Domain/Contracts/AdsenseVisibilityCheckerInterface.php` debe:
- Existir en la ruta correcta
- Definir metodo `check(int $postId, UserContext $userContext): VisibilityDecision`
- NO referenciar WordPress
**Estado**: [ ] Pendiente
**Resultado**:
**Notas**:
---
## Checklist de Despliegue Pre-Pruebas
Antes de ejecutar las pruebas, verificar:
- [ ] Codigo desplegado a produccion via FTP/SSH
- [ ] Cache de pagina limpiado
- [ ] javascript_first_mode habilitado en admin
- [ ] Componente adsense-placement habilitado
- [ ] Schema sincronizado en BD (campo javascript_first_mode existe)
---
## Registro de Ejecucion
| Fecha | Tester | Pruebas Ejecutadas | Pasadas | Fallidas | Notas |
|-------|--------|-------------------|---------|----------|-------|
| | | | | | |
---
## Defectos Encontrados
| ID | Prueba | Descripcion | Severidad | Estado | Correccion |
|----|--------|-------------|-----------|--------|------------|
| | | | | | |
---
## Historial de Versiones
| Version | Fecha | Cambios |
|---------|-------|---------|
| 1.0 | 2025-12-11 | Plan inicial basado en spec v1.5 |