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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
163
Public/AdsensePlacement/Domain/ValueObjects/AdsenseSettings.php
Normal file
163
Public/AdsensePlacement/Domain/ValueObjects/AdsenseSettings.php
Normal 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 !== ''
|
||||
);
|
||||
}
|
||||
}
|
||||
82
Public/AdsensePlacement/Domain/ValueObjects/UserContext.php
Normal file
82
Public/AdsensePlacement/Domain/ValueObjects/UserContext.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user