diff --git a/Assets/Css/css-global-utilities.css b/Assets/Css/css-global-utilities.css index 6d871869..1a80b315 100644 --- a/Assets/Css/css-global-utilities.css +++ b/Assets/Css/css-global-utilities.css @@ -88,3 +88,43 @@ .transition-none { transition: none !important; } + +/* ======================================== + COMPONENT VISIBILITY FAILSAFE (Plan 99.15) + + CSS failsafe: Oculta wrappers de componentes + cuando body tiene clases roi-hide-* + + Estas clases se agregan via BodyClassHooksRegistrar + cuando los componentes están deshabilitados/excluidos. + ======================================== */ + +/* Navbar hidden */ +body.roi-hide-navbar .navbar { + display: none !important; +} + +/* Table of Contents hidden */ +body.roi-hide-toc .roi-toc-container { + display: none !important; +} + +/* CTA Sidebar hidden */ +body.roi-hide-cta-sidebar .roi-cta-box { + display: none !important; +} + +/* Generic sidebar hidden */ +body.roi-hide-sidebar .sidebar-sticky { + display: none !important; +} + +/* When ALL sidebar components are hidden, expand main column */ +body.roi-sidebar-empty .col-lg-9 { + flex: 0 0 100% !important; + max-width: 100% !important; +} + +body.roi-sidebar-empty .col-lg-3 { + display: none !important; +} diff --git a/Inc/adsense-placement.php b/Inc/adsense-placement.php index ba40a3fe..e55d45be 100644 --- a/Inc/adsense-placement.php +++ b/Inc/adsense-placement.php @@ -25,6 +25,8 @@ if (!defined('ABSPATH')) { exit; } +use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper; + /** * Renderiza un slot de anuncio en una ubicacion * @@ -52,11 +54,16 @@ function roi_render_ad_slot(string $location): string return ''; } - // Verificar exclusiones + // Verificar exclusiones legacy (forms group) if (roi_is_ad_excluded($settings)) { return ''; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return ''; + } + // Obtener renderer desde DIContainer (DIP compliant) $renderer = $container->getAdsensePlacementRenderer(); @@ -143,11 +150,16 @@ function roi_render_rail_ads(): string return ''; } - // Verificar exclusiones + // Verificar exclusiones legacy (forms group) if (roi_is_ad_excluded($settings)) { return ''; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return ''; + } + // Obtener renderer desde DIContainer (DIP compliant) $renderer = $container->getAdsensePlacementRenderer(); @@ -193,6 +205,11 @@ function roi_enqueue_adsense_script(): void return; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return; + } + $publisherId = $settings['content']['publisher_id'] ?? ''; if (empty($publisherId)) { return; @@ -246,11 +263,16 @@ function roi_inject_content_ads(string $content): string return $content; } - // Verificar exclusiones + // Verificar exclusiones legacy (forms group) if (roi_is_ad_excluded($settings)) { return $content; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return $content; + } + $renderer = $container->getAdsensePlacementRenderer(); // Inyectar anuncio al inicio (post-top) @@ -446,11 +468,16 @@ function roi_render_anchor_ads(): string return ''; } - // Verificar exclusiones + // Verificar exclusiones legacy (forms group) if (roi_is_ad_excluded($settings)) { return ''; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return ''; + } + // Obtener renderer desde DIContainer (DIP compliant) $renderer = $container->getAdsensePlacementRenderer(); @@ -490,11 +517,16 @@ function roi_render_vignette_ad(): string return ''; } - // Verificar exclusiones + // Verificar exclusiones legacy (forms group) if (roi_is_ad_excluded($settings)) { return ''; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return ''; + } + // Obtener renderer desde DIContainer (DIP compliant) $renderer = $container->getAdsensePlacementRenderer(); @@ -556,6 +588,11 @@ function roi_enqueue_anchor_vignette_scripts(): void return; } + // Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility) + if (!PageVisibilityHelper::shouldShow('adsense-placement')) { + return; + } + // Encolar script wp_enqueue_script( 'roi-anchor-vignette', diff --git a/Shared/Application/UseCases/CheckWrapperVisibilityUseCase.php b/Shared/Application/UseCases/CheckWrapperVisibilityUseCase.php new file mode 100644 index 00000000..ed64a391 --- /dev/null +++ b/Shared/Application/UseCases/CheckWrapperVisibilityUseCase.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/Shared/Domain/Contracts/WrapperVisibilityCheckerInterface.php b/Shared/Domain/Contracts/WrapperVisibilityCheckerInterface.php new file mode 100644 index 00000000..b96b30e7 --- /dev/null +++ b/Shared/Domain/Contracts/WrapperVisibilityCheckerInterface.php @@ -0,0 +1,50 @@ +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']; + } } diff --git a/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php b/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php new file mode 100644 index 00000000..9ff950e4 --- /dev/null +++ b/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php @@ -0,0 +1,109 @@ +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'; + } +} diff --git a/Shared/Infrastructure/Services/WrapperVisibilityService.php b/Shared/Infrastructure/Services/WrapperVisibilityService.php new file mode 100644 index 00000000..9b6bd004 --- /dev/null +++ b/Shared/Infrastructure/Services/WrapperVisibilityService.php @@ -0,0 +1,100 @@ +execute($componentName, $isMobile); + } + + /** + * Verifica visibilidad para múltiples componentes + * + * Útil para determinar si renderizar un contenedor que agrupa varios componentes + * + * @param array $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; + } +} diff --git a/Shared/Infrastructure/Wordpress/BodyClassHooksRegistrar.php b/Shared/Infrastructure/Wordpress/BodyClassHooksRegistrar.php new file mode 100644 index 00000000..7eefefc4 --- /dev/null +++ b/Shared/Infrastructure/Wordpress/BodyClassHooksRegistrar.php @@ -0,0 +1,96 @@ + '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 $classes Clases existentes + * @return array 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; + } +} diff --git a/functions-addon.php b/functions-addon.php index 28090ed9..1070e542 100644 --- a/functions-addon.php +++ b/functions-addon.php @@ -370,6 +370,11 @@ add_action('after_setup_theme', function() { $criticalCSSService = roi_get_critical_css_service(); $hooksRegistrar = new \ROITheme\Shared\Infrastructure\Wordpress\CriticalCSSHooksRegistrar($criticalCSSService); $hooksRegistrar->register(); + + // 3. Body Class Hooks (Plan 99.15) - CSS failsafe para componentes ocultos + $container = \ROITheme\Shared\Infrastructure\Di\DIContainer::getInstance(); + $bodyClassHooksRegistrar = $container->getBodyClassHooksRegistrar(); + $bodyClassHooksRegistrar->register(); }); // ============================================================================= @@ -378,6 +383,47 @@ add_action('after_setup_theme', function() { // NO hardcodear CSS aquí - viola la arquitectura Clean Architecture. // ============================================================================= +// ============================================================================= +// HELPER FUNCTION: roi_should_render_wrapper() - Plan 99.15 +// ============================================================================= + +/** + * Verifica si el wrapper de un componente debe renderizarse + * + * Evalúa: + * - is_enabled + * - show_on_mobile / show_on_desktop + * - Exclusiones (categoría, post ID, URL pattern, page visibility) + * + * USO EN TEMPLATES: + * ```php + * if (roi_should_render_wrapper('navbar')) { + * echo ''; + * } + * ``` + * + * @param string $componentName Nombre del componente (kebab-case) + * @return bool True si el wrapper debe renderizarse + * @see Plan 99.15 - Fix Empty Layout Wrappers + */ +function roi_should_render_wrapper(string $componentName): bool { + return \ROITheme\Shared\Infrastructure\Services\WrapperVisibilityService::shouldRenderWrapper($componentName); +} + +/** + * Verifica si AL MENOS UN componente de una lista debe renderizarse + * + * Útil para determinar si mostrar columna sidebar + * + * @param array $componentNames Lista de nombres de componentes + * @return bool True si al menos uno debe mostrarse + */ +function roi_should_render_any_wrapper(array $componentNames): bool { + return \ROITheme\Shared\Infrastructure\Services\WrapperVisibilityService::shouldRenderAnyWrapper($componentNames); +} + // ============================================================================= // HELPER FUNCTION: roi_get_adsense_search_config() // ============================================================================= diff --git a/header.php b/header.php index 04346f9d..272acdf4 100644 --- a/header.php +++ b/header.php @@ -35,6 +35,10 @@ if (function_exists('roi_render_component')) { ?> + \ No newline at end of file + + \ No newline at end of file diff --git a/page.php b/page.php index 4717c5a4..fc34d719 100644 --- a/page.php +++ b/page.php @@ -26,11 +26,19 @@ if (function_exists('roi_render_component')) { ?> +
- -
+ +
-
+
+
+
diff --git a/single.php b/single.php index ac78d814..fc08b89a 100644 --- a/single.php +++ b/single.php @@ -24,11 +24,19 @@ if (function_exists('roi_render_component')) { ?> +
- -
+ +
-
+
+
+