From 26546e1d69a7fbd92b40b6506faec008a764eb7d Mon Sep 17 00:00:00 2001 From: FrankZamora Date: Thu, 11 Dec 2025 13:03:14 -0600 Subject: [PATCH] feat(api): implement javascript-first architecture for cache compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../CheckAdsenseVisibilityUseCase.php | 36 ++ .../AdsenseVisibilityCheckerInterface.php | 26 + .../Domain/ValueObjects/AdsenseSettings.php | 163 ++++++ .../Domain/ValueObjects/UserContext.php | 82 +++ .../ValueObjects/VisibilityDecision.php | 91 +++ .../WordPress/AdsenseVisibilityController.php | 145 +++++ .../AdsenseJavascriptFirstServiceProvider.php | 113 ++++ .../Services/AdsenseVisibilityChecker.php | 121 ++++ .../Ui/AdsenseAssetsEnqueuer.php | 132 +++++ .../Ui/Assets/adsense-visibility.js | 272 +++++++++ Schemas/adsense-placement.json | 9 +- functions.php | 9 + .../adsense-javascript-first/test-plan.md | 545 ++++++++++++++++++ 13 files changed, 1743 insertions(+), 1 deletion(-) create mode 100644 Public/AdsensePlacement/Application/UseCases/CheckAdsenseVisibilityUseCase.php create mode 100644 Public/AdsensePlacement/Domain/Contracts/AdsenseVisibilityCheckerInterface.php create mode 100644 Public/AdsensePlacement/Domain/ValueObjects/AdsenseSettings.php create mode 100644 Public/AdsensePlacement/Domain/ValueObjects/UserContext.php create mode 100644 Public/AdsensePlacement/Domain/ValueObjects/VisibilityDecision.php create mode 100644 Public/AdsensePlacement/Infrastructure/Api/WordPress/AdsenseVisibilityController.php create mode 100644 Public/AdsensePlacement/Infrastructure/Providers/AdsenseJavascriptFirstServiceProvider.php create mode 100644 Public/AdsensePlacement/Infrastructure/Services/AdsenseVisibilityChecker.php create mode 100644 Public/AdsensePlacement/Infrastructure/Ui/AdsenseAssetsEnqueuer.php create mode 100644 Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js create mode 100644 openspec/specs/adsense-javascript-first/test-plan.md diff --git a/Public/AdsensePlacement/Application/UseCases/CheckAdsenseVisibilityUseCase.php b/Public/AdsensePlacement/Application/UseCases/CheckAdsenseVisibilityUseCase.php new file mode 100644 index 00000000..59cb8a42 --- /dev/null +++ b/Public/AdsensePlacement/Application/UseCases/CheckAdsenseVisibilityUseCase.php @@ -0,0 +1,36 @@ +visibilityChecker->check($postId, $userContext); + } +} diff --git a/Public/AdsensePlacement/Domain/Contracts/AdsenseVisibilityCheckerInterface.php b/Public/AdsensePlacement/Domain/Contracts/AdsenseVisibilityCheckerInterface.php new file mode 100644 index 00000000..cf920d88 --- /dev/null +++ b/Public/AdsensePlacement/Domain/Contracts/AdsenseVisibilityCheckerInterface.php @@ -0,0 +1,26 @@ + $excludedCategoryIds IDs de categorias excluidas + * @param array $excludedPostIds IDs de posts excluidos + * @param array $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 + */ + public function getExcludedCategoryIds(): array + { + return $this->excludedCategoryIds; + } + + /** + * @return array + */ + public function getExcludedPostIds(): array + { + return $this->excludedPostIds; + } + + /** + * @return array + */ + 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> $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 + */ + 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 + */ + 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 !== '' + ); + } +} diff --git a/Public/AdsensePlacement/Domain/ValueObjects/UserContext.php b/Public/AdsensePlacement/Domain/ValueObjects/UserContext.php new file mode 100644 index 00000000..49a75c32 --- /dev/null +++ b/Public/AdsensePlacement/Domain/ValueObjects/UserContext.php @@ -0,0 +1,82 @@ + $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 + */ + 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} $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} + */ + public function toArray(): array + { + return [ + 'is_logged_in' => $this->isLoggedIn, + 'is_mobile' => $this->isMobile, + 'user_roles' => $this->userRoles, + ]; + } +} diff --git a/Public/AdsensePlacement/Domain/ValueObjects/VisibilityDecision.php b/Public/AdsensePlacement/Domain/ValueObjects/VisibilityDecision.php new file mode 100644 index 00000000..0275c76c --- /dev/null +++ b/Public/AdsensePlacement/Domain/ValueObjects/VisibilityDecision.php @@ -0,0 +1,91 @@ + $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 + */ + 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 $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, 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, + ]; + } +} diff --git a/Public/AdsensePlacement/Infrastructure/Api/WordPress/AdsenseVisibilityController.php b/Public/AdsensePlacement/Infrastructure/Api/WordPress/AdsenseVisibilityController.php new file mode 100644 index 00000000..c36a197b --- /dev/null +++ b/Public/AdsensePlacement/Infrastructure/Api/WordPress/AdsenseVisibilityController.php @@ -0,0 +1,145 @@ + '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; + } +} diff --git a/Public/AdsensePlacement/Infrastructure/Providers/AdsenseJavascriptFirstServiceProvider.php b/Public/AdsensePlacement/Infrastructure/Providers/AdsenseJavascriptFirstServiceProvider.php new file mode 100644 index 00000000..30212858 --- /dev/null +++ b/Public/AdsensePlacement/Infrastructure/Providers/AdsenseJavascriptFirstServiceProvider.php @@ -0,0 +1,113 @@ +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; + } +} diff --git a/Public/AdsensePlacement/Infrastructure/Services/AdsenseVisibilityChecker.php b/Public/AdsensePlacement/Infrastructure/Services/AdsenseVisibilityChecker.php new file mode 100644 index 00000000..1616126d --- /dev/null +++ b/Public/AdsensePlacement/Infrastructure/Services/AdsenseVisibilityChecker.php @@ -0,0 +1,121 @@ +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 + */ + 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 : ''; + } +} diff --git a/Public/AdsensePlacement/Infrastructure/Ui/AdsenseAssetsEnqueuer.php b/Public/AdsensePlacement/Infrastructure/Ui/AdsenseAssetsEnqueuer.php new file mode 100644 index 00000000..01751d7e --- /dev/null +++ b/Public/AdsensePlacement/Infrastructure/Ui/AdsenseAssetsEnqueuer.php @@ -0,0 +1,132 @@ +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 + */ + 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; + } +} diff --git a/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js b/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js new file mode 100644 index 00000000..87501012 --- /dev/null +++ b/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js @@ -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(); + } + }; + +})(); diff --git a/Schemas/adsense-placement.json b/Schemas/adsense-placement.json index e682c4b7..a8fbf5e7 100644 --- a/Schemas/adsense-placement.json +++ b/Schemas/adsense-placement.json @@ -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", diff --git a/functions.php b/functions.php index 814461ed..f72c9bac 100644 --- a/functions.php +++ b/functions.php @@ -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 diff --git a/openspec/specs/adsense-javascript-first/test-plan.md b/openspec/specs/adsense-javascript-first/test-plan.md new file mode 100644 index 00000000..eaa7fa8b --- /dev/null +++ b/openspec/specs/adsense-javascript-first/test-plan.md @@ -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 |