diff --git a/Admin/ContactForm/Infrastructure/FieldMapping/ContactFormFieldMapper.php b/Admin/ContactForm/Infrastructure/FieldMapping/ContactFormFieldMapper.php
index 20329657..48231fb5 100644
--- a/Admin/ContactForm/Infrastructure/FieldMapping/ContactFormFieldMapper.php
+++ b/Admin/ContactForm/Infrastructure/FieldMapping/ContactFormFieldMapper.php
@@ -34,6 +34,12 @@ final class ContactFormFieldMapper implements FieldMapperInterface
'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
'contactFormSectionDescription' => ['group' => 'content', 'attribute' => 'section_description'],
diff --git a/Admin/ContactForm/Infrastructure/Ui/ContactFormFormBuilder.php b/Admin/ContactForm/Infrastructure/Ui/ContactFormFormBuilder.php
index 57cc938c..50e7cd9b 100644
--- a/Admin/ContactForm/Infrastructure/Ui/ContactFormFormBuilder.php
+++ b/Admin/ContactForm/Infrastructure/Ui/ContactFormFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Contact Form
@@ -127,6 +128,13 @@ final class ContactFormFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'contactForm');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/CtaLetsTalk/Infrastructure/FieldMapping/CtaLetsTalkFieldMapper.php b/Admin/CtaLetsTalk/Infrastructure/FieldMapping/CtaLetsTalkFieldMapper.php
index c5cc4804..59289c66 100644
--- a/Admin/CtaLetsTalk/Infrastructure/FieldMapping/CtaLetsTalkFieldMapper.php
+++ b/Admin/CtaLetsTalk/Infrastructure/FieldMapping/CtaLetsTalkFieldMapper.php
@@ -34,6 +34,12 @@ final class CtaLetsTalkFieldMapper implements FieldMapperInterface
'ctaLetsTalkVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],
'ctaLetsTalkShowIcon' => ['group' => 'content', 'attribute' => 'show_icon'],
diff --git a/Admin/CtaLetsTalk/Infrastructure/Ui/CtaLetsTalkFormBuilder.php b/Admin/CtaLetsTalk/Infrastructure/Ui/CtaLetsTalkFormBuilder.php
index fecea221..b56ed5d0 100644
--- a/Admin/CtaLetsTalk/Infrastructure/Ui/CtaLetsTalkFormBuilder.php
+++ b/Admin/CtaLetsTalk/Infrastructure/Ui/CtaLetsTalkFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* Class CtaLetsTalkFormBuilder
@@ -154,6 +155,13 @@ final class CtaLetsTalkFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'letsTalk');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/CtaPost/Infrastructure/FieldMapping/CtaPostFieldMapper.php b/Admin/CtaPost/Infrastructure/FieldMapping/CtaPostFieldMapper.php
index 1c7f44ad..e7b0a0a6 100644
--- a/Admin/CtaPost/Infrastructure/FieldMapping/CtaPostFieldMapper.php
+++ b/Admin/CtaPost/Infrastructure/FieldMapping/CtaPostFieldMapper.php
@@ -34,6 +34,12 @@ final class CtaPostFieldMapper implements FieldMapperInterface
'ctaPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],
'ctaPostDescription' => ['group' => 'content', 'attribute' => 'description'],
diff --git a/Admin/CtaPost/Infrastructure/Ui/CtaPostFormBuilder.php b/Admin/CtaPost/Infrastructure/Ui/CtaPostFormBuilder.php
index 7406f633..91584103 100644
--- a/Admin/CtaPost/Infrastructure/Ui/CtaPostFormBuilder.php
+++ b/Admin/CtaPost/Infrastructure/Ui/CtaPostFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para CTA Post
@@ -119,6 +120,13 @@ final class CtaPostFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'ctaPost');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/FeaturedImage/Infrastructure/FieldMapping/FeaturedImageFieldMapper.php b/Admin/FeaturedImage/Infrastructure/FieldMapping/FeaturedImageFieldMapper.php
index b7a08d27..c897df31 100644
--- a/Admin/FeaturedImage/Infrastructure/FieldMapping/FeaturedImageFieldMapper.php
+++ b/Admin/FeaturedImage/Infrastructure/FieldMapping/FeaturedImageFieldMapper.php
@@ -34,6 +34,12 @@ final class FeaturedImageFieldMapper implements FieldMapperInterface
'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],
'featuredImageLazyLoading' => ['group' => 'content', 'attribute' => 'lazy_loading'],
diff --git a/Admin/FeaturedImage/Infrastructure/Ui/FeaturedImageFormBuilder.php b/Admin/FeaturedImage/Infrastructure/Ui/FeaturedImageFormBuilder.php
index 6f47cb96..aa320d6d 100644
--- a/Admin/FeaturedImage/Infrastructure/Ui/FeaturedImageFormBuilder.php
+++ b/Admin/FeaturedImage/Infrastructure/Ui/FeaturedImageFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class FeaturedImageFormBuilder
{
@@ -134,6 +135,13 @@ final class FeaturedImageFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'featuredImage');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/Footer/Infrastructure/FieldMapping/FooterFieldMapper.php b/Admin/Footer/Infrastructure/FieldMapping/FooterFieldMapper.php
index 5e7bb3d5..938caf2f 100644
--- a/Admin/Footer/Infrastructure/FieldMapping/FooterFieldMapper.php
+++ b/Admin/Footer/Infrastructure/FieldMapping/FooterFieldMapper.php
@@ -27,6 +27,19 @@ final class FooterFieldMapper implements FieldMapperInterface
'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'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
'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'],
'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'],
diff --git a/Admin/Footer/Infrastructure/Ui/FooterFormBuilder.php b/Admin/Footer/Infrastructure/Ui/FooterFormBuilder.php
index abb58b30..71f2ab35 100644
--- a/Admin/Footer/Infrastructure/Ui/FooterFormBuilder.php
+++ b/Admin/Footer/Infrastructure/Ui/FooterFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Footer
@@ -90,6 +91,47 @@ final class FooterFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
+ // =============================================
+ // Checkboxes de visibilidad por tipo de página
+ // Grupo especial: _page_visibility
+ // =============================================
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' Mostrar en tipos de pagina';
+ $html .= '
';
+
+ $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 .= ' ';
+ $html .= '
';
+ $html .= $this->buildPageVisibilityCheckbox('footerVisibilityHome', 'Home', 'bi-house', $showOnHome);
+ $html .= '
';
+ $html .= '
';
+ $html .= $this->buildPageVisibilityCheckbox('footerVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
+ $html .= '
';
+ $html .= '
';
+ $html .= $this->buildPageVisibilityCheckbox('footerVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
+ $html .= '
';
+ $html .= '
';
+ $html .= $this->buildPageVisibilityCheckbox('footerVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
+ $html .= '
';
+ $html .= '
';
+ $html .= $this->buildPageVisibilityCheckbox('footerVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
+ $html .= '
';
+ $html .= '
';
+
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'footer');
+
$html .= ' ';
$html .= '';
@@ -410,4 +452,19 @@ final class FooterFormBuilder
}
return (string) $value;
}
+
+ private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
+ {
+ $checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
+
+ $html = '';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= '
';
+
+ return $html;
+ }
}
diff --git a/Admin/Hero/Infrastructure/FieldMapping/HeroFieldMapper.php b/Admin/Hero/Infrastructure/FieldMapping/HeroFieldMapper.php
index 0fd229ed..49d54b58 100644
--- a/Admin/Hero/Infrastructure/FieldMapping/HeroFieldMapper.php
+++ b/Admin/Hero/Infrastructure/FieldMapping/HeroFieldMapper.php
@@ -35,6 +35,12 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'],
diff --git a/Admin/Hero/Infrastructure/Ui/HeroFormBuilder.php b/Admin/Hero/Infrastructure/Ui/HeroFormBuilder.php
index d6fe0337..8ff8b6fe 100644
--- a/Admin/Hero/Infrastructure/Ui/HeroFormBuilder.php
+++ b/Admin/Hero/Infrastructure/Ui/HeroFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class HeroFormBuilder
{
@@ -136,6 +137,13 @@ final class HeroFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'hero');
+
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' ';
diff --git a/Admin/Navbar/Infrastructure/FieldMapping/NavbarFieldMapper.php b/Admin/Navbar/Infrastructure/FieldMapping/NavbarFieldMapper.php
index af1f9244..fe3f1811 100644
--- a/Admin/Navbar/Infrastructure/FieldMapping/NavbarFieldMapper.php
+++ b/Admin/Navbar/Infrastructure/FieldMapping/NavbarFieldMapper.php
@@ -36,6 +36,12 @@ final class NavbarFieldMapper implements FieldMapperInterface
'navbarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],
'navbarPaddingVertical' => ['group' => 'layout', 'attribute' => 'padding_vertical'],
diff --git a/Admin/Navbar/Infrastructure/Ui/NavbarFormBuilder.php b/Admin/Navbar/Infrastructure/Ui/NavbarFormBuilder.php
index f71f64fb..27c0da27 100644
--- a/Admin/Navbar/Infrastructure/Ui/NavbarFormBuilder.php
+++ b/Admin/Navbar/Infrastructure/Ui/NavbarFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class NavbarFormBuilder
{
@@ -139,6 +140,13 @@ final class NavbarFormBuilder
$html .= '
';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'navbar');
+
// Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
$html .= ' ';
diff --git a/Admin/RelatedPost/Infrastructure/FieldMapping/RelatedPostFieldMapper.php b/Admin/RelatedPost/Infrastructure/FieldMapping/RelatedPostFieldMapper.php
index 6bcae3ce..49b11f11 100644
--- a/Admin/RelatedPost/Infrastructure/FieldMapping/RelatedPostFieldMapper.php
+++ b/Admin/RelatedPost/Infrastructure/FieldMapping/RelatedPostFieldMapper.php
@@ -37,6 +37,12 @@ final class RelatedPostFieldMapper implements FieldMapperInterface
'relatedPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
'relatedPostPerPage' => ['group' => 'content', 'attribute' => 'posts_per_page'],
diff --git a/Admin/RelatedPost/Infrastructure/Ui/RelatedPostFormBuilder.php b/Admin/RelatedPost/Infrastructure/Ui/RelatedPostFormBuilder.php
index 4cf3db80..2f36b2bb 100644
--- a/Admin/RelatedPost/Infrastructure/Ui/RelatedPostFormBuilder.php
+++ b/Admin/RelatedPost/Infrastructure/Ui/RelatedPostFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Related Posts
@@ -120,6 +121,13 @@ final class RelatedPostFormBuilder
$html .= '
';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'relatedPost');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/Shared/Infrastructure/Ui/ExclusionFormPartial.php b/Admin/Shared/Infrastructure/Ui/ExclusionFormPartial.php
index dccedc69..2bae8641 100644
--- a/Admin/Shared/Infrastructure/Ui/ExclusionFormPartial.php
+++ b/Admin/Shared/Infrastructure/Ui/ExclusionFormPartial.php
@@ -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)) {
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)) {
return '';
diff --git a/Admin/SocialShare/Infrastructure/FieldMapping/SocialShareFieldMapper.php b/Admin/SocialShare/Infrastructure/FieldMapping/SocialShareFieldMapper.php
index 827e9d70..e9b310cf 100644
--- a/Admin/SocialShare/Infrastructure/FieldMapping/SocialShareFieldMapper.php
+++ b/Admin/SocialShare/Infrastructure/FieldMapping/SocialShareFieldMapper.php
@@ -34,6 +34,12 @@ final class SocialShareFieldMapper implements FieldMapperInterface
'socialShareVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],
'socialShareLabelText' => ['group' => 'content', 'attribute' => 'label_text'],
diff --git a/Admin/SocialShare/Infrastructure/Ui/SocialShareFormBuilder.php b/Admin/SocialShare/Infrastructure/Ui/SocialShareFormBuilder.php
index 7cf4741e..d91d9b7e 100644
--- a/Admin/SocialShare/Infrastructure/Ui/SocialShareFormBuilder.php
+++ b/Admin/SocialShare/Infrastructure/Ui/SocialShareFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Social Share
@@ -128,6 +129,13 @@ final class SocialShareFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'socialShare');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/TableOfContents/Infrastructure/FieldMapping/TableOfContentsFieldMapper.php b/Admin/TableOfContents/Infrastructure/FieldMapping/TableOfContentsFieldMapper.php
index 8860fc5b..18652230 100644
--- a/Admin/TableOfContents/Infrastructure/FieldMapping/TableOfContentsFieldMapper.php
+++ b/Admin/TableOfContents/Infrastructure/FieldMapping/TableOfContentsFieldMapper.php
@@ -34,6 +34,12 @@ final class TableOfContentsFieldMapper implements FieldMapperInterface
'tocVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'tocTitle' => ['group' => 'content', 'attribute' => 'title'],
'tocAutoGenerate' => ['group' => 'content', 'attribute' => 'auto_generate'],
diff --git a/Admin/TableOfContents/Infrastructure/Ui/TableOfContentsFormBuilder.php b/Admin/TableOfContents/Infrastructure/Ui/TableOfContentsFormBuilder.php
index c0866d1e..ef6af736 100644
--- a/Admin/TableOfContents/Infrastructure/Ui/TableOfContentsFormBuilder.php
+++ b/Admin/TableOfContents/Infrastructure/Ui/TableOfContentsFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para la Tabla de Contenido
@@ -128,6 +129,13 @@ final class TableOfContentsFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'toc');
+
$html .= ' ';
$html .= '';
diff --git a/Admin/TopNotificationBar/Infrastructure/FieldMapping/TopNotificationBarFieldMapper.php b/Admin/TopNotificationBar/Infrastructure/FieldMapping/TopNotificationBarFieldMapper.php
index 041a5501..6099dc89 100644
--- a/Admin/TopNotificationBar/Infrastructure/FieldMapping/TopNotificationBarFieldMapper.php
+++ b/Admin/TopNotificationBar/Infrastructure/FieldMapping/TopNotificationBarFieldMapper.php
@@ -35,6 +35,12 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'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
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],
'topBarLabelText' => ['group' => 'content', 'attribute' => 'label_text'],
diff --git a/Admin/TopNotificationBar/Infrastructure/Ui/TopNotificationBarFormBuilder.php b/Admin/TopNotificationBar/Infrastructure/Ui/TopNotificationBarFormBuilder.php
index 5fed2d32..4a3f3d21 100644
--- a/Admin/TopNotificationBar/Infrastructure/Ui/TopNotificationBarFormBuilder.php
+++ b/Admin/TopNotificationBar/Infrastructure/Ui/TopNotificationBarFormBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
+use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class TopNotificationBarFormBuilder
{
@@ -139,6 +140,13 @@ final class TopNotificationBarFormBuilder
$html .= ' ';
$html .= ' ';
+ // =============================================
+ // Reglas de exclusion avanzadas
+ // Grupo especial: _exclusions (Plan 99.11)
+ // =============================================
+ $exclusionPartial = new ExclusionFormPartial($this->renderer);
+ $html .= $exclusionPartial->render($componentId, 'topBar');
+
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' ';
diff --git a/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php b/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php
index 922993b6..fdd56860 100644
--- a/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php
+++ b/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
+use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* Renderer para slots de AdSense
@@ -36,6 +37,11 @@ final class AdsensePlacementRenderer
*/
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
if (!($settings['visibility']['is_enabled'] ?? false)) {
return '';
diff --git a/Public/Footer/Infrastructure/Ui/FooterRenderer.php b/Public/Footer/Infrastructure/Ui/FooterRenderer.php
index b304ef27..e295146f 100644
--- a/Public/Footer/Infrastructure/Ui/FooterRenderer.php
+++ b/Public/Footer/Infrastructure/Ui/FooterRenderer.php
@@ -6,6 +6,7 @@ namespace ROITheme\Public\Footer\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
+use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* FooterRenderer - Renderiza el footer del sitio
@@ -34,9 +35,14 @@ final class FooterRenderer implements RendererInterface
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();
- // Validar visibilidad
+ // Validar visibilidad básica
$visibility = $data['visibility'] ?? [];
if (!($visibility['is_enabled'] ?? true)) {
return '';
diff --git a/Public/HeroSection/Infrastructure/Ui/HeroSectionRenderer.php b/Public/HeroSection/Infrastructure/Ui/HeroSectionRenderer.php
index 72936fc4..680da217 100644
--- a/Public/HeroSection/Infrastructure/Ui/HeroSectionRenderer.php
+++ b/Public/HeroSection/Infrastructure/Ui/HeroSectionRenderer.php
@@ -5,6 +5,7 @@ namespace ROITheme\Public\HeroSection\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
+use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* 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
{
+ // Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
+ if (!PageVisibilityHelper::shouldShow('hero-section')) {
+ return '';
+ }
+
$data = $component->getData();
if (!$this->isEnabled($data)) {
diff --git a/Shared/Domain/ValueObjects/ComponentConfiguration.php b/Shared/Domain/ValueObjects/ComponentConfiguration.php
index 3801287b..d6914f7e 100644
--- a/Shared/Domain/ValueObjects/ComponentConfiguration.php
+++ b/Shared/Domain/ValueObjects/ComponentConfiguration.php
@@ -96,6 +96,9 @@ final readonly class ComponentConfiguration
// Sistema de visibilidad por página
'_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
];
/**
diff --git a/Shared/Domain/ValueObjects/UrlPatternExclusion.php b/Shared/Domain/ValueObjects/UrlPatternExclusion.php
index c7aa9f13..486f3f21 100644
--- a/Shared/Domain/ValueObjects/UrlPatternExclusion.php
+++ b/Shared/Domain/ValueObjects/UrlPatternExclusion.php
@@ -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
{
+ // Detectar si tiene wildcards (*)
+ if (str_contains($pattern, '*')) {
+ return $this->matchesWildcard($pattern, $requestUri, $url);
+ }
+
+ // Substring literal
if ($requestUri !== '' && str_contains($requestUri, $pattern)) {
return true;
}
@@ -101,6 +112,31 @@ final class UrlPatternExclusion extends ExclusionRule
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
{
return !empty($this->urlPatterns);
diff --git a/Shared/Infrastructure/Persistence/WordPress/WordPressComponentSettingsRepository.php b/Shared/Infrastructure/Persistence/WordPress/WordPressComponentSettingsRepository.php
index 2162e938..4e9dd387 100644
--- a/Shared/Infrastructure/Persistence/WordPress/WordPressComponentSettingsRepository.php
+++ b/Shared/Infrastructure/Persistence/WordPress/WordPressComponentSettingsRepository.php
@@ -109,28 +109,67 @@ final class WordPressComponentSettingsRepository implements ComponentSettingsRep
/**
* {@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
{
// Serializar valor
$serializedValue = $this->serializeValue($value);
- // Intentar actualizar
- $result = $this->wpdb->update(
- $this->tableName,
- ['attribute_value' => $serializedValue],
- [
- 'component_name' => $componentName,
- 'group_name' => $groupName,
- 'attribute_name' => $attributeName
- ],
- ['%s'],
- ['%s', '%s', '%s']
- );
+ // Verificar si el registro existe
+ $exists = $this->fieldExists($componentName, $groupName, $attributeName);
+
+ if ($exists) {
+ // UPDATE
+ $result = $this->wpdb->update(
+ $this->tableName,
+ ['attribute_value' => $serializedValue],
+ [
+ 'component_name' => $componentName,
+ '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;
}
+ /**
+ * 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}
*/