fix(exclusions): Corregir Renderers que ignoraban sistema de exclusiones

Plan 99.11 - Correcciones críticas:

- FooterRenderer: Añadir PageVisibilityHelper::shouldShow()
- HeroSectionRenderer: Añadir PageVisibilityHelper::shouldShow()
- AdsensePlacementRenderer: Añadir PageVisibilityHelper::shouldShow()

Mejoras adicionales:
- UrlPatternExclusion: Soporte wildcards (*sct* → regex)
- ExclusionFormPartial: UI mejorada con placeholders
- ComponentConfiguration: Grupo _exclusions validado
- 12 FormBuilders: Integración UI de exclusiones
- 12 FieldMappers: Mapeo campos de exclusión

Verificado: Footer oculto en post con categoría excluida SCT

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-03 19:52:44 -06:00
parent c28fedd6e7
commit f4b45b7e17
29 changed files with 342 additions and 20 deletions

View File

@@ -34,6 +34,12 @@ final class ContactFormFieldMapper implements FieldMapperInterface
'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'contactFormVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'contactFormVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'contactFormExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'contactFormExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'contactFormExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'contactFormExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'], 'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
'contactFormSectionDescription' => ['group' => 'content', 'attribute' => 'section_description'], 'contactFormSectionDescription' => ['group' => 'content', 'attribute' => 'section_description'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui; namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Contact Form * FormBuilder para Contact Form
@@ -127,6 +128,13 @@ final class ContactFormFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'contactForm');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -34,6 +34,12 @@ final class CtaLetsTalkFieldMapper implements FieldMapperInterface
'ctaLetsTalkVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'ctaLetsTalkVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaLetsTalkVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'ctaLetsTalkVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'letsTalkExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'letsTalkExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'letsTalkExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'letsTalkExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'], 'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],
'ctaLetsTalkShowIcon' => ['group' => 'content', 'attribute' => 'show_icon'], 'ctaLetsTalkShowIcon' => ['group' => 'content', 'attribute' => 'show_icon'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui; namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* Class CtaLetsTalkFormBuilder * Class CtaLetsTalkFormBuilder
@@ -154,6 +155,13 @@ final class CtaLetsTalkFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'letsTalk');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -34,6 +34,12 @@ final class CtaPostFieldMapper implements FieldMapperInterface
'ctaPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'ctaPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'ctaPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'ctaPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'ctaPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'ctaPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'ctaPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'], 'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],
'ctaPostDescription' => ['group' => 'content', 'attribute' => 'description'], 'ctaPostDescription' => ['group' => 'content', 'attribute' => 'description'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui; namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para CTA Post * FormBuilder para CTA Post
@@ -119,6 +120,13 @@ final class CtaPostFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'ctaPost');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -34,6 +34,12 @@ final class FeaturedImageFieldMapper implements FieldMapperInterface
'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'featuredImageVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'featuredImageVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'featuredImageExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'featuredImageExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'featuredImageExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'featuredImageExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'], 'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],
'featuredImageLazyLoading' => ['group' => 'content', 'attribute' => 'lazy_loading'], 'featuredImageLazyLoading' => ['group' => 'content', 'attribute' => 'lazy_loading'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui; namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class FeaturedImageFormBuilder final class FeaturedImageFormBuilder
{ {
@@ -134,6 +135,13 @@ final class FeaturedImageFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'featuredImage');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -27,6 +27,19 @@ final class FooterFieldMapper implements FieldMapperInterface
'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'footerShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'footerShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Page Visibility (grupo especial _page_visibility)
'footerVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'footerVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'footerVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'footerVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'footerVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'footerExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'footerExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'footerExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'footerExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Widget 1 // Widget 1
'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'], 'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'],
'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'], 'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui; namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Footer * FormBuilder para Footer
@@ -90,6 +91,47 @@ final class FooterFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'footer');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';
@@ -410,4 +452,19 @@ final class FooterFormBuilder
} }
return (string) $value; return (string) $value;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
{
$checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
$html = '<div class="form-check">';
$html .= ' <input class="form-check-input" type="checkbox" id="' . esc_attr($id) . '" ' . $checked . '>';
$html .= ' <label class="form-check-label small" for="' . esc_attr($id) . '">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= '</div>';
return $html;
}
} }

View File

@@ -35,6 +35,12 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'heroVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'heroVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'heroExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'heroExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'heroExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'heroExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'], 'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'], 'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui; namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class HeroFormBuilder final class HeroFormBuilder
{ {
@@ -136,6 +137,13 @@ final class HeroFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'hero');
// Switch: CSS Crítico // Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true); $isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0 mt-3">'; $html .= ' <div class="mb-0 mt-3">';

View File

@@ -36,6 +36,12 @@ final class NavbarFieldMapper implements FieldMapperInterface
'navbarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'navbarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'navbarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'navbarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'navbarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'navbarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'navbarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'navbarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Layout // Layout
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'], 'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],
'navbarPaddingVertical' => ['group' => 'layout', 'attribute' => 'padding_vertical'], 'navbarPaddingVertical' => ['group' => 'layout', 'attribute' => 'padding_vertical'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\Ui; namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class NavbarFormBuilder final class NavbarFormBuilder
{ {
@@ -139,6 +140,13 @@ final class NavbarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'navbar');
// Switch: Sticky // Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true); $sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
$html .= ' <div class="mb-2">'; $html .= ' <div class="mb-2">';

View File

@@ -37,6 +37,12 @@ final class RelatedPostFieldMapper implements FieldMapperInterface
'relatedPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'relatedPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'relatedPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'relatedPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'relatedPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'relatedPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'relatedPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'relatedPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'], 'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
'relatedPostPerPage' => ['group' => 'content', 'attribute' => 'posts_per_page'], 'relatedPostPerPage' => ['group' => 'content', 'attribute' => 'posts_per_page'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui; namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Related Posts * FormBuilder para Related Posts
@@ -120,6 +121,13 @@ final class RelatedPostFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'relatedPost');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -210,11 +210,19 @@ final class ExclusionFormPartial
} }
/** /**
* Convierte JSON array a lista separada por comas * Convierte JSON array o array a lista separada por comas
*
* @param string|array $value Valor desde BD (puede ser JSON string o array ya deserializado)
*/ */
private function jsonToCommaList(string $json): string private function jsonToCommaList(string|array $value): string
{ {
$decoded = json_decode($json, true); // Si ya es array, usarlo directamente
if (is_array($value)) {
return empty($value) ? '' : implode(', ', $value);
}
// Si es string, intentar decodificar JSON
$decoded = json_decode($value, true);
if (!is_array($decoded) || empty($decoded)) { if (!is_array($decoded) || empty($decoded)) {
return ''; return '';
@@ -224,11 +232,19 @@ final class ExclusionFormPartial
} }
/** /**
* Convierte JSON array a lista separada por lineas * Convierte JSON array o array a lista separada por lineas
*
* @param string|array $value Valor desde BD (puede ser JSON string o array ya deserializado)
*/ */
private function jsonToLineList(string $json): string private function jsonToLineList(string|array $value): string
{ {
$decoded = json_decode($json, true); // Si ya es array, usarlo directamente
if (is_array($value)) {
return empty($value) ? '' : implode("\n", $value);
}
// Si es string, intentar decodificar JSON
$decoded = json_decode($value, true);
if (!is_array($decoded) || empty($decoded)) { if (!is_array($decoded) || empty($decoded)) {
return ''; return '';

View File

@@ -34,6 +34,12 @@ final class SocialShareFieldMapper implements FieldMapperInterface
'socialShareVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'socialShareVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'socialShareVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'socialShareVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'socialShareExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'socialShareExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'socialShareExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'socialShareExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'], 'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],
'socialShareLabelText' => ['group' => 'content', 'attribute' => 'label_text'], 'socialShareLabelText' => ['group' => 'content', 'attribute' => 'label_text'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui; namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Social Share * FormBuilder para Social Share
@@ -128,6 +129,13 @@ final class SocialShareFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'socialShare');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -34,6 +34,12 @@ final class TableOfContentsFieldMapper implements FieldMapperInterface
'tocVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'tocVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'tocVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'tocVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'tocExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'tocExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'tocExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'tocExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'tocTitle' => ['group' => 'content', 'attribute' => 'title'], 'tocTitle' => ['group' => 'content', 'attribute' => 'title'],
'tocAutoGenerate' => ['group' => 'content', 'attribute' => 'auto_generate'], 'tocAutoGenerate' => ['group' => 'content', 'attribute' => 'auto_generate'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui; namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para la Tabla de Contenido * FormBuilder para la Tabla de Contenido
@@ -128,6 +129,13 @@ final class TableOfContentsFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'toc');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';

View File

@@ -35,6 +35,12 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'], 'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'topBarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'], 'topBarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'topBarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'topBarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'topBarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'topBarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'], 'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],
'topBarLabelText' => ['group' => 'content', 'attribute' => 'label_text'], 'topBarLabelText' => ['group' => 'content', 'attribute' => 'label_text'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui; namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class TopNotificationBarFormBuilder final class TopNotificationBarFormBuilder
{ {
@@ -139,6 +140,13 @@ final class TopNotificationBarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'topBar');
// Switch: CSS Crítico // Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true); $isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0 mt-3">'; $html .= ' <div class="mb-0 mt-3">';

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui; namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* Renderer para slots de AdSense * Renderer para slots de AdSense
@@ -36,6 +37,11 @@ final class AdsensePlacementRenderer
*/ */
public function renderSlot(array $settings, string $location): string public function renderSlot(array $settings, string $location): string
{ {
// 0. Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return '';
}
// 1. Validar is_enabled // 1. Validar is_enabled
if (!($settings['visibility']['is_enabled'] ?? false)) { if (!($settings['visibility']['is_enabled'] ?? false)) {
return ''; return '';

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Footer\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface; use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* FooterRenderer - Renderiza el footer del sitio * FooterRenderer - Renderiza el footer del sitio
@@ -34,9 +35,14 @@ final class FooterRenderer implements RendererInterface
public function render(Component $component): string public function render(Component $component): string
{ {
// Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
if (!PageVisibilityHelper::shouldShow('footer')) {
return '';
}
$data = $component->getData(); $data = $component->getData();
// Validar visibilidad // Validar visibilidad básica
$visibility = $data['visibility'] ?? []; $visibility = $data['visibility'] ?? [];
if (!($visibility['is_enabled'] ?? true)) { if (!($visibility['is_enabled'] ?? true)) {
return ''; return '';

View File

@@ -5,6 +5,7 @@ namespace ROITheme\Public\HeroSection\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface; use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* HeroSectionRenderer - Renderiza la sección hero con badges y título * HeroSectionRenderer - Renderiza la sección hero con badges y título
@@ -23,6 +24,11 @@ final class HeroSectionRenderer implements RendererInterface
{ {
public function render(Component $component): string public function render(Component $component): string
{ {
// Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
if (!PageVisibilityHelper::shouldShow('hero-section')) {
return '';
}
$data = $component->getData(); $data = $component->getData();
if (!$this->isEnabled($data)) { if (!$this->isEnabled($data)) {

View File

@@ -96,6 +96,9 @@ final readonly class ComponentConfiguration
// Sistema de visibilidad por página // Sistema de visibilidad por página
'_page_visibility', // Visibilidad por tipo de página (home, posts, pages, archives, search) '_page_visibility', // Visibilidad por tipo de página (home, posts, pages, archives, search)
// Sistema de exclusiones (Plan 99.11)
'_exclusions', // Reglas de exclusión por categoría, post ID, URL pattern
]; ];
/** /**

View File

@@ -86,10 +86,21 @@ final class UrlPatternExclusion extends ExclusionRule
} }
/** /**
* Evalua coincidencia por substring * Evalua coincidencia por substring o wildcard
*
* Soporta wildcards simples:
* - `*sct*` coincide con URLs que contengan "sct"
* - `*` se convierte a `.*` en regex
* - Sin wildcards: busca substring literal
*/ */
private function matchesSubstring(string $pattern, string $requestUri, string $url): bool private function matchesSubstring(string $pattern, string $requestUri, string $url): bool
{ {
// Detectar si tiene wildcards (*)
if (str_contains($pattern, '*')) {
return $this->matchesWildcard($pattern, $requestUri, $url);
}
// Substring literal
if ($requestUri !== '' && str_contains($requestUri, $pattern)) { if ($requestUri !== '' && str_contains($requestUri, $pattern)) {
return true; return true;
} }
@@ -101,6 +112,31 @@ final class UrlPatternExclusion extends ExclusionRule
return false; return false;
} }
/**
* Evalua coincidencia con patron wildcard
*
* Convierte wildcards (*) a regex (.*)
*/
private function matchesWildcard(string $pattern, string $requestUri, string $url): bool
{
// Convertir wildcard a regex:
// 1. Escapar caracteres especiales de regex (excepto *)
// 2. Convertir * a .*
$regexPattern = preg_quote($pattern, '#');
$regexPattern = str_replace('\\*', '.*', $regexPattern);
$regexPattern = '#' . $regexPattern . '#i';
if ($requestUri !== '' && preg_match($regexPattern, $requestUri) === 1) {
return true;
}
if ($url !== '' && preg_match($regexPattern, $url) === 1) {
return true;
}
return false;
}
public function hasValues(): bool public function hasValues(): bool
{ {
return !empty($this->urlPatterns); return !empty($this->urlPatterns);

View File

@@ -109,28 +109,67 @@ final class WordPressComponentSettingsRepository implements ComponentSettingsRep
/** /**
* {@inheritDoc} * {@inheritDoc}
*
* Implementa UPSERT: si el registro no existe, lo crea; si existe, lo actualiza.
* Esto es necesario para grupos especiales como _page_visibility y _exclusions
* que no vienen del schema JSON.
*/ */
public function saveFieldValue(string $componentName, string $groupName, string $attributeName, mixed $value): bool public function saveFieldValue(string $componentName, string $groupName, string $attributeName, mixed $value): bool
{ {
// Serializar valor // Serializar valor
$serializedValue = $this->serializeValue($value); $serializedValue = $this->serializeValue($value);
// Intentar actualizar // Verificar si el registro existe
$result = $this->wpdb->update( $exists = $this->fieldExists($componentName, $groupName, $attributeName);
$this->tableName,
['attribute_value' => $serializedValue], if ($exists) {
[ // UPDATE
'component_name' => $componentName, $result = $this->wpdb->update(
'group_name' => $groupName, $this->tableName,
'attribute_name' => $attributeName ['attribute_value' => $serializedValue],
], [
['%s'], 'component_name' => $componentName,
['%s', '%s', '%s'] 'group_name' => $groupName,
); 'attribute_name' => $attributeName
],
['%s'],
['%s', '%s', '%s']
);
} else {
// INSERT - crear nuevo registro
$result = $this->wpdb->insert(
$this->tableName,
[
'component_name' => $componentName,
'group_name' => $groupName,
'attribute_name' => $attributeName,
'attribute_value' => $serializedValue
],
['%s', '%s', '%s', '%s']
);
}
return $result !== false; return $result !== false;
} }
/**
* Verifica si un campo existe en la BD
*/
private function fieldExists(string $componentName, string $groupName, string $attributeName): bool
{
$sql = $this->wpdb->prepare(
"SELECT COUNT(*) FROM {$this->tableName}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s",
$componentName,
$groupName,
$attributeName
);
return (int) $this->wpdb->get_var($sql) > 0;
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */