fix(wrappers): eliminar wrappers vacíos y corregir exclusiones AdSense (Plan 99.15)

## Problema
- Componentes deshabilitados/excluidos dejaban wrappers HTML vacíos
  (navbar 32px, sidebar col-lg-3 294px)
- AdSense ignoraba exclusiones por URL pattern en grupo _exclusions

## Solución Plan 99.15 (Clean Architecture)

### Domain Layer
- WrapperVisibilityCheckerInterface: contrato para verificar visibilidad

### Application Layer
- CheckWrapperVisibilityUseCase: orquesta verificaciones de visibilidad

### Infrastructure Layer
- WordPressComponentVisibilityRepository: consulta BD + PageVisibilityHelper
- WrapperVisibilityService: facade estático para templates
- BodyClassHooksRegistrar: agrega clases CSS failsafe al body

### Templates modificados
- header.php: renderizado condicional de <nav> wrapper
- page.php/single.php: lógica dinámica col-lg-9/col-lg-12 según sidebar

### CSS Failsafe
- css-global-utilities.css: reglas body.roi-hide-* como respaldo

## Fix AdSense (Inc/adsense-placement.php)
- Agregado PageVisibilityHelper::shouldShow() a todas las funciones:
  roi_render_ad_slot, roi_render_rail_ads, roi_enqueue_adsense_script,
  roi_inject_content_ads, roi_render_anchor_ads, roi_render_vignette_ad,
  roi_enqueue_anchor_vignette_scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-04 11:46:21 -06:00
parent 23339e3349
commit 36d5cf56de
12 changed files with 617 additions and 12 deletions

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases;
use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
/**
* UseCase: Verificar si un wrapper de componente debe renderizarse
*
* Responsabilidad: Orquestar la lógica de verificación de visibilidad
* combinando múltiples criterios:
* 1. Componente habilitado (is_enabled)
* 2. Visible en dispositivo actual (show_on_mobile/desktop)
* 3. No excluido por reglas (categoría, post ID, URL, page visibility)
*
* @package ROITheme\Shared\Application\UseCases
* @see Plan 99.15 - Fix Empty Layout Wrappers
*/
final class CheckWrapperVisibilityUseCase
{
public function __construct(
private readonly WrapperVisibilityCheckerInterface $visibilityChecker
) {}
/**
* Ejecuta la verificación de visibilidad del wrapper
*
* @param string $componentName Nombre del componente (kebab-case)
* @param bool $isMobile True si es dispositivo móvil
* @return bool True si el wrapper debe renderizarse
*/
public function execute(string $componentName, bool $isMobile): bool
{
// Criterio 1: Debe estar habilitado
if (!$this->visibilityChecker->isEnabled($componentName)) {
return false;
}
// Criterio 2: Debe ser visible en el dispositivo actual
if (!$this->visibilityChecker->isVisibleOnDevice($componentName, $isMobile)) {
return false;
}
// Criterio 3: No debe estar excluido
if (!$this->visibilityChecker->isNotExcluded($componentName)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para verificar visibilidad de wrappers de componentes
*
* Responsabilidad: Definir contrato para determinar si un wrapper
* de componente debe renderizarse basándose en:
* - Estado habilitado/deshabilitado
* - Visibilidad por dispositivo
* - Reglas de exclusión (categoría, post ID, URL pattern, page visibility)
*
* @package ROITheme\Shared\Domain\Contracts
* @see Plan 99.15 - Fix Empty Layout Wrappers
*/
interface WrapperVisibilityCheckerInterface
{
/**
* Verifica si el componente está habilitado globalmente
*
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si is_enabled = true en BD
*/
public function isEnabled(string $componentName): bool;
/**
* Verifica si el componente es visible en el dispositivo actual
*
* @param string $componentName Nombre del componente (kebab-case)
* @param bool $isMobile True si es dispositivo móvil
* @return bool True si show_on_mobile/show_on_desktop según corresponda
*/
public function isVisibleOnDevice(string $componentName, bool $isMobile): bool;
/**
* Verifica si el componente NO está excluido para la página actual
*
* Evalúa todas las reglas de exclusión:
* - Exclusión por categoría
* - Exclusión por post ID
* - Exclusión por URL pattern
* - Page visibility (home, posts, pages, archives, search)
*
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si el componente NO está excluido
*/
public function isNotExcluded(string $componentName): bool;
}

View File

@@ -37,6 +37,11 @@ use ROITheme\Shared\Infrastructure\Services\WordPressPageContextProvider;
use ROITheme\Shared\Infrastructure\Services\WordPressServerRequestProvider;
use ROITheme\Shared\Application\UseCases\EvaluateExclusions\EvaluateExclusionsUseCase;
use ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility\EvaluateComponentVisibilityUseCase;
// Wrapper Visibility System (Plan 99.15)
use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentVisibilityRepository;
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
use ROITheme\Shared\Infrastructure\Wordpress\BodyClassHooksRegistrar;
/**
* DIContainer - Contenedor de Inyección de Dependencias
@@ -443,4 +448,49 @@ final class DIContainer
}
return $this->instances['evaluateComponentVisibilityUseCase'];
}
// ===============================
// Wrapper Visibility System (Plan 99.15)
// ===============================
/**
* Obtiene el repositorio de visibilidad de wrappers
*
* Implementa WrapperVisibilityCheckerInterface
*/
public function getWrapperVisibilityChecker(): WrapperVisibilityCheckerInterface
{
if (!isset($this->instances['wrapperVisibilityChecker'])) {
$this->instances['wrapperVisibilityChecker'] = new WordPressComponentVisibilityRepository($this->wpdb);
}
return $this->instances['wrapperVisibilityChecker'];
}
/**
* Obtiene el caso de uso para verificar visibilidad de wrappers
*
* Usado por WrapperVisibilityService para templates
*/
public function getCheckWrapperVisibilityUseCase(): CheckWrapperVisibilityUseCase
{
if (!isset($this->instances['checkWrapperVisibilityUseCase'])) {
$this->instances['checkWrapperVisibilityUseCase'] = new CheckWrapperVisibilityUseCase(
$this->getWrapperVisibilityChecker()
);
}
return $this->instances['checkWrapperVisibilityUseCase'];
}
/**
* Obtiene el registrador de hooks para body_class
*
* CSS failsafe: Agrega clases cuando componentes están ocultos
*/
public function getBodyClassHooksRegistrar(): BodyClassHooksRegistrar
{
if (!isset($this->instances['bodyClassHooksRegistrar'])) {
$this->instances['bodyClassHooksRegistrar'] = new BodyClassHooksRegistrar();
}
return $this->instances['bodyClassHooksRegistrar'];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Persistence\WordPress;
use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* Implementación de WrapperVisibilityCheckerInterface para WordPress
*
* Responsabilidad: Consultar BD y evaluar visibilidad de wrappers de componentes
*
* - Consulta tabla wp_roi_theme_component_settings para is_enabled, show_on_mobile, show_on_desktop
* - Delega evaluación de exclusiones a PageVisibilityHelper (DRY)
*
* @package ROITheme\Shared\Infrastructure\Persistence\WordPress
* @see Plan 99.15 - Fix Empty Layout Wrappers
*/
final class WordPressComponentVisibilityRepository implements WrapperVisibilityCheckerInterface
{
private string $tableName;
public function __construct(
private \wpdb $wpdb
) {
$this->tableName = $this->wpdb->prefix . 'roi_theme_component_settings';
}
/**
* {@inheritDoc}
*/
public function isEnabled(string $componentName): bool
{
$value = $this->getVisibilityAttribute($componentName, 'is_enabled');
// Si no existe el registro, asumir habilitado por defecto
if ($value === null) {
return true;
}
return $this->toBool($value);
}
/**
* {@inheritDoc}
*/
public function isVisibleOnDevice(string $componentName, bool $isMobile): bool
{
$attribute = $isMobile ? 'show_on_mobile' : 'show_on_desktop';
$value = $this->getVisibilityAttribute($componentName, $attribute);
// Si no existe el registro, asumir visible por defecto
if ($value === null) {
return true;
}
return $this->toBool($value);
}
/**
* {@inheritDoc}
*
* Delega a PageVisibilityHelper que ya implementa:
* - Visibilidad por tipo de página (home, posts, pages, archives, search)
* - Exclusiones por categoría, post ID, URL pattern
*/
public function isNotExcluded(string $componentName): bool
{
return PageVisibilityHelper::shouldShow($componentName);
}
/**
* Obtiene un atributo del grupo visibility desde la BD
*
* @param string $componentName
* @param string $attributeName
* @return string|null
*/
private function getVisibilityAttribute(string $componentName, string $attributeName): ?string
{
$sql = $this->wpdb->prepare(
"SELECT attribute_value
FROM {$this->tableName}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s
LIMIT 1",
$componentName,
'visibility',
$attributeName
);
$result = $this->wpdb->get_var($sql);
return $result !== null ? (string) $result : null;
}
/**
* Convierte string a boolean
*
* @param string $value
* @return bool
*/
private function toBool(string $value): bool
{
return $value === '1' || strtolower($value) === 'true';
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
/**
* Servicio facade para verificar visibilidad de wrappers desde templates
*
* Responsabilidad: Proveer acceso simplificado (singleton/static) al
* CheckWrapperVisibilityUseCase para uso en templates PHP.
*
* USO EN TEMPLATES:
* ```php
* if (WrapperVisibilityService::shouldRenderWrapper('navbar')) {
* // Renderizar wrapper y componente
* }
* ```
*
* @package ROITheme\Shared\Infrastructure\Services
* @see Plan 99.15 - Fix Empty Layout Wrappers
*/
final class WrapperVisibilityService
{
private static ?CheckWrapperVisibilityUseCase $useCase = null;
/**
* Verifica si el wrapper de un componente debe renderizarse
*
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si el wrapper debe renderizarse
*/
public static function shouldRenderWrapper(string $componentName): bool
{
$useCase = self::getUseCase();
$isMobile = self::detectMobile();
return $useCase->execute($componentName, $isMobile);
}
/**
* Verifica visibilidad para múltiples componentes
*
* Útil para determinar si renderizar un contenedor que agrupa varios componentes
*
* @param array<string> $componentNames Lista de nombres de componentes
* @return bool True si AL MENOS UNO de los componentes debe mostrarse
*/
public static function shouldRenderAnyWrapper(array $componentNames): bool
{
foreach ($componentNames as $componentName) {
if (self::shouldRenderWrapper($componentName)) {
return true;
}
}
return false;
}
/**
* Obtiene o crea el UseCase
*
* @return CheckWrapperVisibilityUseCase
*/
private static function getUseCase(): CheckWrapperVisibilityUseCase
{
if (self::$useCase === null) {
$container = DIContainer::getInstance();
self::$useCase = $container->getCheckWrapperVisibilityUseCase();
}
return self::$useCase;
}
/**
* Detecta si el dispositivo actual es móvil
*
* Usa wp_is_mobile() de WordPress
*
* @return bool
*/
private static function detectMobile(): bool
{
if (function_exists('wp_is_mobile')) {
return wp_is_mobile();
}
return false;
}
/**
* Limpia la instancia del UseCase (útil para tests)
*/
public static function reset(): void
{
self::$useCase = null;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Wordpress;
use ROITheme\Shared\Infrastructure\Services\WrapperVisibilityService;
/**
* Registra hook body_class para agregar clases CSS de componentes ocultos
*
* RESPONSABILIDAD:
* - Registrar hook body_class
* - Agregar clases CSS cuando componentes están ocultos
*
* FLUJO:
* 1. body_class filter → addHiddenComponentClasses()
* - Verifica visibilidad de componentes clave (navbar, sidebar components)
* - Agrega clases: roi-hide-navbar, roi-hide-sidebar, etc.
*
* PROPÓSITO:
* Failsafe CSS: Si los templates no pueden ocultar wrappers completamente,
* estas clases permiten ocultarlos via CSS.
*
* PATRÓN:
* - SRP: Solo registra hooks, delega lógica a WrapperVisibilityService
*
* @package ROITheme\Shared\Infrastructure\Wordpress
* @see Plan 99.15 - Fix Empty Layout Wrappers
*/
final class BodyClassHooksRegistrar
{
/**
* Componentes que afectan el layout principal
*/
private const LAYOUT_COMPONENTS = [
'navbar' => 'roi-hide-navbar',
'table-of-contents' => 'roi-hide-toc',
'cta-box-sidebar' => 'roi-hide-cta-sidebar',
'sidebar' => 'roi-hide-sidebar',
];
/**
* Componentes de sidebar que determinan si mostrar columna lateral
*/
private const SIDEBAR_COMPONENTS = [
'table-of-contents',
'cta-box-sidebar',
];
/**
* Registrar hooks de WordPress
*/
public function register(): void
{
add_filter('body_class', [$this, 'addHiddenComponentClasses']);
}
/**
* Callback para body_class - agrega clases para componentes ocultos
*
* @param array<string> $classes Clases existentes
* @return array<string> Clases modificadas
*/
public function addHiddenComponentClasses(array $classes): array
{
// Agregar clase por cada componente oculto
foreach (self::LAYOUT_COMPONENTS as $componentName => $cssClass) {
if (!WrapperVisibilityService::shouldRenderWrapper($componentName)) {
$classes[] = $cssClass;
}
}
// Verificar si TODOS los componentes de sidebar están ocultos
if ($this->allSidebarComponentsHidden()) {
$classes[] = 'roi-sidebar-empty';
}
return $classes;
}
/**
* Verifica si todos los componentes de sidebar están ocultos
*
* @return bool True si ningún componente de sidebar debe mostrarse
*/
private function allSidebarComponentsHidden(): bool
{
foreach (self::SIDEBAR_COMPONENTS as $componentName) {
if (WrapperVisibilityService::shouldRenderWrapper($componentName)) {
return false;
}
}
return true;
}
}