10 Commits

Author SHA1 Message Date
FrankZamora
f4b45b7e17 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>
2025-12-03 19:52:44 -06:00
FrankZamora
c28fedd6e7 feat(exclusions): Integrate exclusion UI in CtaBoxSidebar component
- Add ExclusionFormPartial to CtaBoxSidebarFormBuilder
- Add _exclusions fields to CtaBoxSidebarFieldMapper with types
- Update AdminAjaxHandler to process json_array types via ExclusionFieldProcessor
- Enqueue exclusion-toggle.js in AdminAssetEnqueuer

The exclusion UI now appears in CTA Box Sidebar visibility section,
allowing admins to exclude component from specific categories,
post IDs, or URL patterns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:05:52 -06:00
FrankZamora
14138e7762 feat(exclusions): Implement component exclusion system (Plan 99.11)
Adds ability to exclude components from specific:
- Categories (by slug or term_id)
- Post/Page IDs
- URL patterns (substring or regex)

Architecture:
- Domain: Value Objects (CategoryExclusion, PostIdExclusion,
  UrlPatternExclusion, ExclusionRuleSet) + Contracts
- Application: EvaluateExclusionsUseCase +
  EvaluateComponentVisibilityUseCase (orchestrator)
- Infrastructure: WordPressExclusionRepository,
  WordPressPageContextProvider, WordPressServerRequestProvider
- Admin: ExclusionFormPartial (reusable UI),
  ExclusionFieldProcessor, JS toggle

The PageVisibilityHelper now uses the orchestrator UseCase that
combines page-type visibility (Plan 99.10) with exclusion rules.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:51:00 -06:00
FrankZamora
8735962f52 feat(visibility): sistema de visibilidad por tipo de página
- Añadir PageVisibility use case y repositorio
- Implementar PageTypeDetector para detectar home/single/page/archive
- Actualizar FieldMappers con soporte show_on_[page_type]
- Extender FormBuilders con UI de visibilidad por página
- Refactorizar Renderers para evaluar visibilidad dinámica
- Limpiar schemas removiendo campos de visibilidad legacy
- Añadir MigrationCommand para migrar configuraciones existentes
- Implementar adsense-loader.js para carga lazy de ads
- Actualizar front-page.php con nueva estructura
- Extender DIContainer con nuevos servicios

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 09:16:34 -06:00
FrankZamora
7fb5eda108 refactor(template): unificar page.php con estructura de single.php
- Grid layout col-lg-9 + col-lg-3
- Incluye todos los componentes: hero, featured-image, social-share,
  cta-post, related-post, table-of-contents, cta-box-sidebar, contact-form
- Permite que cta-box-sidebar se muestre en páginas cuando show_on_pages=all

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:36:36 -06:00
FrankZamora
4cdc4db397 fix(css): bump bootstrap-subset version to force cache refresh
Changed version from 5.3.2-subset to 5.3.2-subset-2 to invalidate
browser/CDN cache after removing position:relative from .navbar.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:23:42 -06:00
FrankZamora
c732b5af05 fix(css): remove position:relative from .navbar in bootstrap-subset
Post-process PurgeCSS output to remove position:relative from .navbar,
allowing CriticalCSSService's position:sticky to take effect.

This prevents bootstrap-subset.min.css from overriding the navbar
sticky positioning set by the critical CSS system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:20:37 -06:00
FrankZamora
29a69617e4 fix(navbar): permitir position sticky dinámico
Eliminar position:relative hardcodeado de .navbar en critical-bootstrap.css
para que CriticalCSSService pueda establecer position:sticky según
la configuración sticky_enabled en BD.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:54:47 -06:00
FrankZamora
9e37ea93eb fix(icons): cargar bootstrap-icons como CSS crítico
- Remover bootstrap-icons de ROI_DEFERRED_CSS
- Cambiar media='print' a media='all'
- Solo 4.4KB - no impacta PageSpeed significativamente
- Elimina flash/parpadeo de iconos en carga inicial

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:40:16 -06:00
FrankZamora
7472dbad11 revert: restaurar Poppins - parpadeo de iconos persiste
Revertir cambio a system fonts porque el parpadeo de iconos
(Bootstrap Icons) sigue presente, haciendo el cambio innecesario.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:37:52 -06:00
98 changed files with 4946 additions and 737 deletions

View File

@@ -95,6 +95,16 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface
'adsense-placementVignetteReshowEnabled' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_enabled'],
'adsense-placementVignetteReshowTime' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_time'],
'adsense-placementVignetteMaxPerSession' => ['group' => 'vignette_ads', 'attribute' => 'vignette_max_per_session'],
// SEARCH RESULTS (ROI APU Search)
'adsense-placementSearchAdsEnabled' => ['group' => 'search_results', 'attribute' => 'search_ads_enabled'],
'adsense-placementSearchTopAdEnabled' => ['group' => 'search_results', 'attribute' => 'search_top_ad_enabled'],
'adsense-placementSearchTopAdFormat' => ['group' => 'search_results', 'attribute' => 'search_top_ad_format'],
'adsense-placementSearchBetweenEnabled' => ['group' => 'search_results', 'attribute' => 'search_between_enabled'],
'adsense-placementSearchBetweenMax' => ['group' => 'search_results', 'attribute' => 'search_between_max'],
'adsense-placementSearchBetweenFormat' => ['group' => 'search_results', 'attribute' => 'search_between_format'],
'adsense-placementSearchBetweenPosition' => ['group' => 'search_results', 'attribute' => 'search_between_position'],
'adsense-placementSearchBetweenEvery' => ['group' => 'search_results', 'attribute' => 'search_between_every'],
];
}
}

View File

@@ -57,6 +57,7 @@ final class AdsensePlacementFormBuilder
$html .= $this->buildRailAdsGroup($componentId);
$html .= $this->buildAnchorAdsGroup($componentId);
$html .= $this->buildVignetteAdsGroup($componentId);
$html .= $this->buildSearchResultsGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
@@ -708,6 +709,101 @@ final class AdsensePlacementFormBuilder
return $html;
}
/**
* Seccion para anuncios en resultados de busqueda (ROI APU Search)
*/
private function buildSearchResultsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #fd7e14;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-search me-2" style="color: #fd7e14;"></i>';
$html .= ' Resultados de Busqueda';
$html .= ' <span class="badge bg-secondary ms-2">ROI APU Search</span>';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">Insertar anuncios en los resultados del buscador de Analisis de Precios Unitarios.</p>';
// Master switch
$searchAdsEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_ads_enabled', false);
$html .= $this->buildSwitch($cid . 'SearchAdsEnabled', 'Activar ads en busqueda', $searchAdsEnabled, 'bi-power');
// Anuncio superior
$html .= '<div class="border rounded p-3 mb-3" style="background: #fff8f0;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge" style="background: #fd7e14;">ANUNCIO SUPERIOR</span>';
$html .= ' <small class="text-muted">Debajo del campo de busqueda</small>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$topEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_top_ad_enabled', true);
$html .= $this->buildSwitch($cid . 'SearchTopAdEnabled', 'Activar', $topEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$topFormat = $this->renderer->getFieldValue($cid, 'search_results', 'search_top_ad_format', 'auto');
$html .= $this->buildSelect($cid . 'SearchTopAdFormat', 'Formato',
(string)$topFormat,
['auto' => 'Auto (responsive)', 'display' => 'Display (fijo)', 'in-article' => 'In-Article (fluid)']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
// Anuncios entre resultados
$html .= '<div class="border rounded p-3" style="background: #fff8f0;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge" style="background: #fd7e14;">ENTRE RESULTADOS</span>';
$html .= ' <small class="text-muted">Intercalados con los resultados</small>';
$html .= '</div>';
$html .= '<div class="row g-2 mb-2">';
$html .= ' <div class="col-md-6">';
$betweenEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_enabled', true);
$html .= $this->buildSwitch($cid . 'SearchBetweenEnabled', 'Activar', $betweenEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$betweenMax = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_max', '1');
$html .= $this->buildSelect($cid . 'SearchBetweenMax', 'Maximo ads',
(string)$betweenMax,
['1' => '1 anuncio', '2' => '2 anuncios', '3' => '3 anuncios (max)']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '<div class="row g-2 mb-2">';
$html .= ' <div class="col-md-6">';
$betweenFormat = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_format', 'in-article');
$html .= $this->buildSelect($cid . 'SearchBetweenFormat', 'Formato',
(string)$betweenFormat,
['in-article' => 'In-Article (fluid)', 'auto' => 'Auto (responsive)', 'autorelaxed' => 'Autorelaxed (feed)']
);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$betweenPosition = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_position', 'random');
$html .= $this->buildSelect($cid . 'SearchBetweenPosition', 'Posicion',
(string)$betweenPosition,
['random' => 'Aleatorio', 'fixed' => 'Fijo (cada N)', 'first_half' => 'Primera mitad']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$betweenEvery = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_every', '5');
$html .= $this->buildSelect($cid . 'SearchBetweenEvery', 'Cada N resultados (si es fijo)',
(string)$betweenEvery,
['3' => 'Cada 3', '4' => 'Cada 4', '5' => 'Cada 5', '6' => 'Cada 6', '7' => 'Cada 7', '8' => 'Cada 8', '10' => 'Cada 10']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildExclusionsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #6c757d;">';

View File

@@ -26,7 +26,19 @@ final class ContactFormFieldMapper implements FieldMapperInterface
'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'contactFormShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'contactFormVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'contactFormVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'contactFormVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
@@ -93,18 +94,46 @@ final class ContactFormFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="contactFormShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="contactFormShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilitySearch', '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, 'contactForm');
$html .= ' </div>';
$html .= '</div>';
@@ -598,4 +627,26 @@ final class ContactFormFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -30,7 +30,19 @@ final class CtaBoxSidebarFieldMapper implements FieldMapperInterface
'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'ctaVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'ctaExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'ctaExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'ctaExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'ctaExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para el CTA Box Sidebar
@@ -94,19 +95,48 @@ final class CtaBoxSidebarFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="ctaShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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>';
// Obtener valores de _page_visibility (grupo especial)
$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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
// Grid 3 columnas según Design System
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilitySearch', '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, 'cta');
$html .= ' </div>';
$html .= '</div>';
@@ -515,4 +545,29 @@ final class CtaBoxSidebarFormBuilder
return $html;
}
/**
* Genera un checkbox de visibilidad por tipo de pagina
*
* Sigue Design System: form-check-checkbox es obligatorio
*/
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, bool $checked): string
{
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -26,7 +26,19 @@ final class CtaLetsTalkFieldMapper implements FieldMapperInterface
'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaLetsTalkShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'ctaLetsTalkVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaLetsTalkVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaLetsTalkVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
@@ -120,17 +121,46 @@ final class CtaLetsTalkFormBuilder
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
$html .= ' <select id="ctaLetsTalkShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilitySearch', '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, 'letsTalk');
$html .= ' </div>';
$html .= '</div>';
@@ -447,4 +477,26 @@ final class CtaLetsTalkFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -26,7 +26,19 @@ final class CtaPostFieldMapper implements FieldMapperInterface
'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'ctaPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
@@ -85,18 +86,46 @@ final class CtaPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaPostShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="ctaPostShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilitySearch', '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, 'ctaPost');
$html .= ' </div>';
$html .= '</div>';
@@ -437,4 +466,26 @@ final class CtaPostFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -26,7 +26,19 @@ final class FeaturedImageFieldMapper implements FieldMapperInterface
'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'featuredImageShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'featuredImageVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'featuredImageVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'featuredImageVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
{
@@ -100,18 +101,46 @@ final class FeaturedImageFormBuilder
$html .= ' </div>';
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="featuredImageShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="featuredImageShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilitySearch', '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, 'featuredImage');
$html .= ' </div>';
$html .= '</div>';
@@ -119,6 +148,28 @@ final class FeaturedImageFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';

View File

@@ -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'],

View File

@@ -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 .= ' <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>';
@@ -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 = '<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

@@ -26,9 +26,21 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'heroVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'heroVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'heroVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
{
@@ -102,19 +103,46 @@ final class HeroFormBuilder
$html .= ' </div>';
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="heroShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilitySearch', '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, 'hero');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
@@ -427,4 +455,26 @@ final class HeroFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -107,6 +107,15 @@ final class AdminAssetEnqueuer
true
);
// Script de toggle para exclusiones (Plan 99.11)
wp_enqueue_script(
'roi-exclusion-toggle',
$this->themeUri . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js',
['roi-admin-dashboard'],
filemtime(get_template_directory() . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js'),
true
);
// Pasar variables al JavaScript
wp_localize_script(
'roi-admin-dashboard',

View File

@@ -26,10 +26,22 @@ final class NavbarFieldMapper implements FieldMapperInterface
'navbarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'navbarShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'navbarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'],
'navbarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'navbarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'navbarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'navbarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
{
@@ -105,17 +106,46 @@ final class NavbarFormBuilder
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
$html .= ' <select id="navbarShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
// =============================================
// 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('navbarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilitySearch', '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, 'navbar');
// Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
@@ -527,4 +557,26 @@ final class NavbarFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -29,7 +29,19 @@ final class RelatedPostFieldMapper implements FieldMapperInterface
'relatedPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'relatedPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'relatedPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'relatedPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'relatedPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'relatedPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'relatedPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
@@ -86,18 +87,46 @@ final class RelatedPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="relatedPostShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="relatedPostShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilitySearch', '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, 'relatedPost');
$html .= ' </div>';
$html .= '</div>';
@@ -498,4 +527,26 @@ final class RelatedPostFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -5,6 +5,7 @@ namespace ROITheme\Admin\Shared\Infrastructure\Api\WordPress;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
use ROITheme\Admin\Shared\Infrastructure\FieldMapping\FieldMapperRegistry;
use ROITheme\Admin\Shared\Infrastructure\Services\ExclusionFieldProcessor;
/**
* Handler para peticiones AJAX del panel de administracion
@@ -73,10 +74,16 @@ final class AdminAjaxHandler
/**
* Mapea settings de field IDs a grupos/atributos
*
* Soporta tipos especiales para campos de exclusion:
* - json_array: Convierte "a, b, c" a ["a", "b", "c"]
* - json_array_int: Convierte "1, 2, 3" a [1, 2, 3]
* - json_array_lines: Convierte lineas a array
*/
private function mapSettings(array $settings, array $fieldMapping): array
{
$mappedSettings = [];
$fieldProcessor = new ExclusionFieldProcessor();
foreach ($settings as $fieldId => $value) {
if (!isset($fieldMapping[$fieldId])) {
@@ -86,11 +93,17 @@ final class AdminAjaxHandler
$mapping = $fieldMapping[$fieldId];
$groupName = $mapping['group'];
$attributeName = $mapping['attribute'];
$type = $mapping['type'] ?? null;
if (!isset($mappedSettings[$groupName])) {
$mappedSettings[$groupName] = [];
}
// Procesar valor segun tipo
if ($type !== null && is_string($value)) {
$value = $fieldProcessor->process($value, $type);
}
$mappedSettings[$groupName][$attributeName] = $value;
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Services;
/**
* Servicio para procesar campos de exclusion antes de guardar en BD
*
* Convierte formatos de UI a JSON para almacenamiento.
*
* v1.1: Extraido de AdminAjaxHandler (SRP)
*
* @package ROITheme\Admin\Shared\Infrastructure\Services
*/
final class ExclusionFieldProcessor
{
/**
* Procesa un valor de campo de exclusion segun su tipo
*
* @param string $value Valor del campo (desde UI)
* @param string $type Tipo de campo: json_array, json_array_int, json_array_lines
* @return string JSON string para almacenar en BD
*/
public function process(string $value, string $type): string
{
return match ($type) {
'json_array' => $this->processJsonArray($value),
'json_array_int' => $this->processJsonArrayInt($value),
'json_array_lines' => $this->processJsonArrayLines($value),
default => $value,
};
}
/**
* "a, b, c" -> ["a", "b", "c"]
*/
private function processJsonArray(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_UNICODE);
}
/**
* "1, 2, 3" -> [1, 2, 3]
*/
private function processJsonArrayInt(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, 'is_numeric');
$items = array_map('intval', $items);
return json_encode(array_values($items));
}
/**
* Lineas separadas -> array
*/
private function processJsonArrayLines(string $value): string
{
$items = preg_split('/\r\n|\r|\n/', $value);
$items = array_map('trim', $items);
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}

View File

@@ -0,0 +1,31 @@
/**
* Toggle para mostrar/ocultar reglas de exclusion en FormBuilders
*
* Escucha cambios en checkboxes con ID que termine en "ExclusionsEnabled"
* y muestra/oculta el contenedor de reglas correspondiente.
*
* @package ROITheme\Admin
*/
(function() {
'use strict';
function initExclusionToggles() {
document.querySelectorAll('[id$="ExclusionsEnabled"]').forEach(function(checkbox) {
// Handler para cambios
checkbox.addEventListener('change', function() {
const prefix = this.id.replace('ExclusionsEnabled', '');
const rulesContainer = document.getElementById(prefix + 'ExclusionRules');
if (rulesContainer) {
rulesContainer.style.display = this.checked ? 'block' : 'none';
}
});
});
}
// Inicializar cuando DOM este listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initExclusionToggles);
} else {
initExclusionToggles();
}
})();

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* Componente UI parcial reutilizable para reglas de exclusion
*
* Genera el HTML para la seccion de exclusiones en FormBuilders.
* Debe ser incluido despues de la seccion de visibilidad por tipo de pagina.
*
* Uso en FormBuilder:
* ```php
* $exclusionPartial = new ExclusionFormPartial($this->renderer);
* $html .= $exclusionPartial->render($componentId, 'prefijo');
* ```
*
* @package ROITheme\Admin\Shared\Infrastructure\Ui
*/
final class ExclusionFormPartial
{
private const GROUP_NAME = '_exclusions';
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {}
/**
* Renderiza la seccion de exclusiones
*
* @param string $componentId ID del componente (kebab-case)
* @param string $prefix Prefijo para IDs de campos (ej: 'cta' genera 'ctaExclusionsEnabled')
* @return string HTML de la seccion
*/
public function render(string $componentId, string $prefix): string
{
$html = '';
$html .= $this->buildExclusionHeader();
$html .= $this->buildExclusionToggle($componentId, $prefix);
$html .= $this->buildExclusionRules($componentId, $prefix);
return $html;
}
private function buildExclusionHeader(): string
{
$html = '<hr class="my-3">';
$html .= '<p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-funnel me-1" style="color: #FF8600;"></i>';
$html .= ' Reglas de exclusion avanzadas';
$html .= '</p>';
$html .= '<p class="small text-muted mb-2">';
$html .= ' Excluir este componente de categorias, posts o URLs especificos.';
$html .= '</p>';
return $html;
}
private function buildExclusionToggle(string $componentId, string $prefix): string
{
$enabled = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclusions_enabled',
false
);
$checked = $this->toBool($enabled);
$id = $prefix . 'ExclusionsEnabled';
$html = '<div class="mb-3">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= ' <i class="bi bi-filter-circle me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Activar reglas de exclusion</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildExclusionRules(string $componentId, string $prefix): string
{
$enabled = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclusions_enabled',
false
);
$display = $this->toBool($enabled) ? 'block' : 'none';
$html = sprintf(
'<div id="%sExclusionRules" style="display: %s;">',
esc_attr($prefix),
$display
);
$html .= $this->buildCategoryField($componentId, $prefix);
$html .= $this->buildPostIdsField($componentId, $prefix);
$html .= $this->buildUrlPatternsField($componentId, $prefix);
$html .= '</div>';
return $html;
}
private function buildCategoryField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_categories',
'[]'
);
$categories = $this->jsonToCommaList($value);
$id = $prefix . 'ExcludeCategories';
$html = '<div class="mb-3">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-folder me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir en categorias';
$html .= ' </label>';
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="noticias, eventos, tutoriales">',
esc_attr($id),
esc_attr($categories)
);
$html .= ' <small class="text-muted">Slugs de categorias separados por comas</small>';
$html .= '</div>';
return $html;
}
private function buildPostIdsField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_post_ids',
'[]'
);
$postIds = $this->jsonToCommaList($value);
$id = $prefix . 'ExcludePostIds';
$html = '<div class="mb-3">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-hash me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir en posts/paginas';
$html .= ' </label>';
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="123, 456, 789">',
esc_attr($id),
esc_attr($postIds)
);
$html .= ' <small class="text-muted">IDs de posts o paginas separados por comas</small>';
$html .= '</div>';
return $html;
}
private function buildUrlPatternsField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_url_patterns',
'[]'
);
$patterns = $this->jsonToLineList($value);
$id = $prefix . 'ExcludeUrlPatterns';
$html = '<div class="mb-0">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir por patrones URL';
$html .= ' </label>';
$html .= sprintf(
' <textarea id="%s" class="form-control form-control-sm" rows="3" placeholder="/privado/&#10;/landing-especial/&#10;/^\/categoria\/\d+$/">%s</textarea>',
esc_attr($id),
esc_textarea($patterns)
);
$html .= ' <small class="text-muted">Un patron por linea. Soporta texto simple o regex (ej: /^\/blog\/\d+$/)</small>';
$html .= '</div>';
return $html;
}
/**
* 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|array $value): string
{
// 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 '';
}
return implode(', ', $decoded);
}
/**
* 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|array $value): string
{
// 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 '';
}
return implode("\n", $decoded);
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}

View File

@@ -26,7 +26,19 @@ final class SocialShareFieldMapper implements FieldMapperInterface
'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'socialShareShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'socialShareVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'socialShareVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'socialShareVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
@@ -94,19 +95,46 @@ final class SocialShareFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="socialShareShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="socialShareShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilitySearch', '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, 'socialShare');
$html .= ' </div>';
$html .= '</div>';
@@ -526,4 +554,26 @@ final class SocialShareFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -26,7 +26,19 @@ final class TableOfContentsFieldMapper implements FieldMapperInterface
'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'tocShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'tocVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'tocVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'tocVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
@@ -94,19 +95,46 @@ final class TableOfContentsFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="tocShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="tocShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilitySearch', '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, 'toc');
$html .= ' </div>';
$html .= '</div>';
@@ -585,4 +613,26 @@ final class TableOfContentsFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -26,9 +26,21 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'topBarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'topBarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'topBarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'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'],

View File

@@ -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
{
@@ -105,20 +106,46 @@ final class TopNotificationBarFormBuilder
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="topBarShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
// =============================================
// 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', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilitySearch', '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, 'topBar');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
@@ -319,4 +346,26 @@ final class TopNotificationBarFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -5,7 +5,7 @@
* NO contiene CSS personalizado (ese va en critical-custom-temp.css - TIPO 3).
*
* Componentes Bootstrap incluidos:
* - System Fonts (CERO flash - sin @font-face externos)
* - Fonts (@font-face Poppins)
* - Variables CSS (:root)
* - Resets (box-sizing, body)
* - Container system
@@ -30,29 +30,45 @@
*/
/* ==========================================================================
SYSTEM FONTS - CERO Flash (sin fuentes externas)
CRITICAL FONTS (Poppins - LCP optimization)
Usa fuentes nativas del sistema operativo:
- macOS/iOS: -apple-system, BlinkMacSystemFont
- Windows: Segoe UI
- Android: Roboto
- Linux: Ubuntu/Cantarell
- Fallback: sans-serif
VENTAJAS:
- 0 KB descarga (fuentes ya instaladas)
- 0 flash/parpadeo (disponibles instantaneamente)
- Mejor rendimiento LCP/FCP
- Familiar para usuarios (fuentes nativas)
font-display: swap + preload = fuente carga rapido y siempre se muestra
size-adjust: 100.6% = fallback casi identico a Poppins (minimiza CLS)
========================================================================== */
@font-face {
font-family: 'Poppins Fallback';
src: local('Arial'), local('Helvetica Neue'), local('sans-serif');
size-adjust: 106%;
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
@font-face {
font-family: 'Poppins';
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
/* System Font Stack - CERO flash garantizado */
--font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", "Liberation Sans",
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-primary: var(--font-system);
--bs-body-font-family: var(--font-system);
/* Fonts */
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
--bs-body-font-family: 'Poppins', 'Poppins Fallback', sans-serif;
/* Theme Colors (críticos para above-the-fold) */
--color-navy-dark: #0E2337;
@@ -372,7 +388,7 @@ button:focus:not(:focus-visible) {
--bs-navbar-toggler-border-radius: var(--bs-border-radius, 0.375rem);
--bs-navbar-toggler-focus-width: 0.25rem;
--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;
position: relative;
/* position: controlado por CriticalCSSService según sticky_enabled */
display: flex;
flex-wrap: wrap;
align-items: center;

View File

@@ -2,12 +2,10 @@
* Sistema de Tipografías - ROI Theme
*
* RESPONSABILIDAD: SOLO definición de fuentes y variables tipográficas
* - Declaraciones @font-face (comentadas - usar Google Fonts)
* - Variables CSS de tipografía (:root)
* - Clases utilitarias de fuentes
*
* NOTA: Usando SYSTEM FONTS para CERO flash/parpadeo
* Las fuentes del sistema están disponibles instantáneamente.
*
* NO debe contener:
* - Estilos de body (van en style.css)
* - Estilos de elementos HTML (van en style.css)
@@ -18,20 +16,20 @@
*/
/* ============================================
SYSTEM FONTS - CERO Flash
SYSTEM FONTS (Por defecto - Recomendado)
============================================ */
:root {
/* Stack de fuentes del sistema - disponibles instantáneamente */
/* Stack de fuentes del sistema - Fallback */
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans',
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
/* Fuente primaria - System fonts (CERO flash) */
--font-primary: var(--font-system);
/* Fuente primaria - Poppins con fallback ajustado (Fase 4.3 PageSpeed)
'Poppins Fallback' tiene size-adjust para reducir CLS durante font swap */
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para encabezados - System fonts */
--font-headings: var(--font-system);
/* Fuente para encabezados - Poppins con fallback ajustado */
--font-headings: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para código (monospace) */
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono',
@@ -48,22 +46,70 @@
*/
/* ============================================
POPPINS - DESHABILITADO
POPPINS (Self-hosted)
============================================
Las @font-face de Poppins fueron eliminadas para
garantizar CERO flash/parpadeo en la carga de página.
Fuentes Poppins alojadas localmente para:
- Eliminar dependencia de Google Fonts
- Mejorar rendimiento (sin requests externos)
- Cumplimiento GDPR (sin tracking de Google)
El sitio ahora usa fuentes del sistema (--font-system)
que están disponibles instantáneamente en todos los
dispositivos sin necesidad de descarga.
Pesos incluidos: 400, 500, 600, 700
Formato: WOFF2 (mejor compresión)
Para reactivar Poppins en el futuro, descomentar las
declaraciones @font-face y actualizar las variables
--font-primary y --font-headings.
Fase 4.3 PageSpeed: Fallback con size-adjust para reducir CLS
- size-adjust: 100.6% ajustado para coincidir mejor con Poppins
- font-display: swap + preload = carga rapida sin salto visual
- Preload en CriticalCSSInjector P:-2 acelera descarga de fuentes
NOTA: El valor 100.6% fue calibrado empiricamente.
- 106% causaba un salto visual notable (navbar se "achicaba")
- 100.6% minimiza el CLS manteniendo legibilidad del fallback
============================================ */
/* Fallback font con metricas ajustadas para Poppins */
@font-face {
font-family: 'Poppins Fallback';
src: local('Arial'), local('Helvetica Neue'), local('Helvetica'), local('sans-serif');
size-adjust: 106%;
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ============================================
UTILIDADES DE FUENTES
============================================ */

View File

@@ -182,10 +182,72 @@
}, CONFIG.timeout);
}
/**
* Activa slots de AdSense insertados dinamicamente
* Escucha el evento 'roi-adsense-activate' disparado por otros scripts
*/
function setupDynamicAdsListener() {
window.addEventListener('roi-adsense-activate', function() {
debugLog('Evento roi-adsense-activate recibido');
// Si AdSense aun no ha cargado, forzar carga ahora
if (!adsenseLoaded) {
debugLog('AdSense no cargado, forzando carga...');
loadAdSense();
return;
}
// AdSense ya cargado - activar nuevos slots
debugLog('Activando nuevos slots dinamicos...');
activateDynamicSlots();
});
}
/**
* Activa slots de AdSense que fueron insertados despues de la carga inicial
*/
function activateDynamicSlots() {
// Buscar scripts de push que aun no han sido ejecutados
var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]');
if (pendingPushScripts.length === 0) {
debugLog('No hay slots pendientes por activar');
return;
}
debugLog('Activando ' + pendingPushScripts.length + ' slot(s) dinamico(s)');
// Asegurar que adsbygoogle existe
window.adsbygoogle = window.adsbygoogle || [];
pendingPushScripts.forEach(function(oldScript) {
try {
// Crear nuevo script ejecutable
var newScript = document.createElement('script');
newScript.type = 'text/javascript';
newScript.innerHTML = oldScript.innerHTML;
// Reemplazar el placeholder con el script real
oldScript.parentNode.replaceChild(newScript, oldScript);
} catch (e) {
debugLog('Error activando slot: ' + e.message);
}
});
}
/**
* Inicializa el cargador retrasado de AdSense
*/
function init() {
// =========================================================================
// NUEVO: Siempre configurar listener para ads dinamicos
// IMPORTANTE: Esto debe ejecutarse ANTES del early return
// porque los ads dinamicos pueden necesitar activarse aunque
// el delay global este deshabilitado
// =========================================================================
setupDynamicAdsListener();
debugLog('Listener para ads dinamicos configurado');
// Verificar si el retardo de AdSense está habilitado
if (!window.roiAdsenseDelayed) {
debugLog('Retardo de AdSense no habilitado');

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,7 @@ define('ROI_DEFERRED_CSS', [
'roi-utilities',
'roi-accessibility',
'roi-responsive',
'bootstrap-icons',
// NOTA: bootstrap-icons REMOVIDO de diferido - ahora crítico para evitar flash
]);
/**
@@ -125,19 +125,19 @@ function roi_enqueue_bootstrap() {
'roi-bootstrap',
get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css',
array('roi-fonts'),
'5.3.2-subset',
'5.3.2-subset-2', // v2: removed position:relative from .navbar
'print' // DIFERIDO - critical CSS inline evita CLS
);
// Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed)
// Original: 211 KB (2050 iconos) -> Subset: 13 KB (104 iconos) = 94% reduccion
// DIFERIDO: Fase 4.3 - no crítico para renderizado inicial
// CRITICO: Carga inmediata para evitar flash de iconos (4.4KB)
wp_enqueue_style(
'bootstrap-icons',
get_template_directory_uri() . '/Assets/Vendor/bootstrap-icons-subset.min.css',
array('roi-bootstrap'),
ROI_VERSION,
'print'
'all' // CRITICO - no diferir para evitar parpadeo de iconos
);
// Variables CSS del Template RDash - DIFERIDO

View File

@@ -50,6 +50,13 @@ function roi_get_featured_image($post_id = null, $size = 'roi-featured-large', $
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista, no solo el attachment ID
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return ''; // Archivo no existe en servidor
}
// Obtener tipo de post
$post_type = get_post_type($post_id);
@@ -145,6 +152,13 @@ function roi_get_post_thumbnail($post_id = null, $with_link = true) {
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return '';
}
// Obtener la imagen con clases Bootstrap
$image = get_the_post_thumbnail($post_id, 'roi-featured-medium', array(
'class' => 'img-fluid post-thumbnail',
@@ -216,6 +230,13 @@ function roi_get_post_thumbnail_small($post_id = null, $with_link = true) {
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return '';
}
// Obtener la imagen
$image = get_the_post_thumbnail($post_id, 'roi-thumbnail', array(
'class' => 'img-fluid post-thumbnail-small',
@@ -287,6 +308,13 @@ function roi_should_show_featured_image($post_id = null) {
return false;
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return false;
}
// Obtener tipo de post
$post_type = get_post_type($post_id);
@@ -338,6 +366,13 @@ function roi_get_featured_image_url($post_id = null, $size = 'roi-featured-large
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return '';
}
// Obtener URL de la imagen
$image_url = get_the_post_thumbnail_url($post_id, $size);

View File

@@ -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 '';

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\ContactForm\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;
/**
* ContactFormRenderer - Renderiza formulario de contacto con webhook
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class ContactFormRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'contact-form';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -34,7 +37,7 @@ final class ContactFormRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -67,7 +70,7 @@ final class ContactFormRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'contact-form';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -76,22 +79,6 @@ final class ContactFormRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;

View File

@@ -9,11 +9,10 @@ use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
* Inyecta CSS critico en wp_head
*
* Prioridades:
* - P:-2 Font preload (antes de variables)
* - P:-1 Variables CSS (antes de Bootstrap)
* - P:2 Responsive critico (despues de Bootstrap critico)
*
* NOTA: Font preload deshabilitado - usando system fonts para CERO flash
*
* @package ROITheme\Public\CriticalCSS\Infrastructure\Services
*/
final class CriticalCSSInjector
@@ -22,11 +21,23 @@ final class CriticalCSSInjector
private readonly CriticalCSSCacheInterface $cache
) {}
/**
* Fuentes criticas para preload (pesos usados en navbar above-the-fold)
*/
private const CRITICAL_FONTS = [
'/Assets/Fonts/poppins-v24-latin-regular.woff2', // 400 - body text
'/Assets/Fonts/poppins-v24-latin-600.woff2', // 600 - navbar brand
'/Assets/Fonts/poppins-v24-latin-700.woff2', // 700 - headings
];
/**
* Registra hooks de WordPress
*/
public function register(): void
{
// Font preload: P:-2 (antes de todo, incluso variables)
add_action('wp_head', [$this, 'preloadFonts'], -2);
// Variables CSS: P:-1 (antes de CriticalBootstrapService P:0)
add_action('wp_head', [$this, 'injectVariables'], -1);
@@ -38,6 +49,25 @@ final class CriticalCSSInjector
add_action('wp_enqueue_scripts', [$this, 'dequeueInlinedCSS'], 999);
}
/**
* Inyecta preload links para fuentes criticas
*
* Resuelve el problema de "font swap" donde el fallback (106% size-adjust)
* causa un salto visual cuando Poppins se carga.
* Con preload, las fuentes llegan antes del primer paint.
*/
public function preloadFonts(): void
{
echo "<!-- TIPO 4: Font preload para evitar CLS -->\n";
foreach (self::CRITICAL_FONTS as $font) {
printf(
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
esc_url(get_template_directory_uri() . $font)
);
}
}
/**
* Inyecta variables CSS criticas
*/

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\CtaBoxSidebar\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;
/**
* CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar
@@ -27,6 +28,12 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class CtaBoxSidebarRenderer implements RendererInterface
{
/**
* Nombre del componente para visibilidad
* Evita strings hardcodeados y facilita mantenimiento
*/
private const COMPONENT_NAME = 'cta-box-sidebar';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -39,7 +46,8 @@ final class CtaBoxSidebarRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
// Evaluar visibilidad por tipo de página (usa Helper, NO cambia constructor)
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -52,7 +60,7 @@ final class CtaBoxSidebarRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'cta-box-sidebar';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -60,22 +68,6 @@ final class CtaBoxSidebarRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\CtaLetsTalk\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;
/**
* Class CtaLetsTalkRenderer
@@ -34,6 +35,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class CtaLetsTalkRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'cta-lets-talk';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -54,7 +57,7 @@ final class CtaLetsTalkRenderer implements RendererInterface
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -77,7 +80,7 @@ final class CtaLetsTalkRenderer implements RendererInterface
*/
public function supports(string $componentType): bool
{
return $componentType === 'cta-lets-talk';
return $componentType === self::COMPONENT_NAME;
}
/**
@@ -91,25 +94,6 @@ final class CtaLetsTalkRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
default => true,
};
}
/**
* Calcular clases de visibilidad responsive
*

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\CtaPost\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;
/**
* CtaPostRenderer - Renderiza CTA promocional debajo del contenido
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class CtaPostRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'cta-post';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -34,7 +37,7 @@ final class CtaPostRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -46,7 +49,7 @@ final class CtaPostRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'cta-post';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -55,22 +58,6 @@ final class CtaPostRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\FeaturedImage\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;
/**
* FeaturedImageRenderer - Renderiza la imagen destacada del post
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class FeaturedImageRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'featured-image';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -39,7 +42,7 @@ final class FeaturedImageRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -63,7 +66,7 @@ final class FeaturedImageRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'featured-image';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -71,25 +74,24 @@ final class FeaturedImageRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function hasPostThumbnail(): bool
{
return is_singular() && has_post_thumbnail();
if (!is_singular() || !has_post_thumbnail()) {
return false;
}
// Verificar que el archivo físico exista, no solo el attachment ID
$thumbnailId = get_post_thumbnail_id();
if (!$thumbnailId) {
return false;
}
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return false;
}
return true;
}
/**

View File

@@ -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 '';

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Hero\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;
/**
* Class HeroRenderer
@@ -33,6 +34,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class HeroRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'hero';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -48,7 +51,7 @@ final class HeroRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -68,7 +71,7 @@ final class HeroRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'hero';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -76,24 +79,6 @@ final class HeroRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'home':
return is_front_page() || is_home();
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
/**
* Generar CSS usando CSSGeneratorService
*

View File

@@ -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)) {

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Navbar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use Walker_Nav_Menu;
/**
@@ -28,6 +29,8 @@ use Walker_Nav_Menu;
*/
final class NavbarRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'navbar';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -43,6 +46,10 @@ final class NavbarRenderer implements RendererInterface
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$html = $this->buildMenu($data);
// Si is_critical=true, CSS ya fue inyectado en <head> por CriticalCSSService
@@ -281,7 +288,7 @@ final class NavbarRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'navbar';
return $componentType === self::COMPONENT_NAME;
}
}

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\RelatedPost\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;
/**
* RelatedPostRenderer - Renderiza seccion de posts relacionados
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class RelatedPostRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'related-post';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -34,7 +37,7 @@ final class RelatedPostRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -51,7 +54,7 @@ final class RelatedPostRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'related-post';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -60,22 +63,6 @@ final class RelatedPostRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\SocialShare\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;
/**
* SocialShareRenderer - Renderiza botones de compartir en redes sociales
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class SocialShareRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'social-share';
private const NETWORKS = [
'facebook' => [
'field' => 'show_facebook',
@@ -84,7 +87,7 @@ final class SocialShareRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -96,7 +99,7 @@ final class SocialShareRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'social-share';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -105,22 +108,6 @@ final class SocialShareRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\TableOfContents\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;
use DOMDocument;
use DOMXPath;
@@ -30,6 +31,8 @@ use DOMXPath;
*/
final class TableOfContentsRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'table-of-contents';
private array $headingCounter = [];
public function __construct(
@@ -44,7 +47,7 @@ final class TableOfContentsRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -63,7 +66,7 @@ final class TableOfContentsRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'table-of-contents';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -71,22 +74,6 @@ final class TableOfContentsRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\TopNotificationBar\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;
/**
* Class TopNotificationBarRenderer
@@ -34,6 +35,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class TopNotificationBarRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'top-notification-bar';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -54,7 +57,7 @@ final class TopNotificationBarRenderer implements RendererInterface
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -78,7 +81,7 @@ final class TopNotificationBarRenderer implements RendererInterface
*/
public function supports(string $componentType): bool
{
return $componentType === 'top-notification-bar';
return $componentType === self::COMPONENT_NAME;
}
/**
@@ -92,46 +95,6 @@ final class TopNotificationBarRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
'custom' => $this->isInCustomPages($data),
default => true,
};
}
/**
* Verificar si está en páginas personalizadas
*
* @param array $data Datos del componente
* @return bool
*/
private function isInCustomPages(array $data): bool
{
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
if (empty($pageIds)) {
return false;
}
$allowedIds = array_map('trim', explode(',', $pageIds));
$currentId = (string) get_the_ID();
return in_array($currentId, $allowedIds, true);
}
/**
* Verificar si el componente fue dismissed por el usuario
*

View File

@@ -110,3 +110,14 @@
transform: rotate(360deg);
}
}
/* ========================================
FIX: Legacy wrapper with padding-top
Removes duplicate aspect-ratio from parent
containers that use the old padding-top trick
(prevents double spacing above videos)
======================================== */
div[style*="padding-top"]:has(> .youtube-facade) {
padding-top: 0 !important;
}

View File

@@ -437,6 +437,72 @@
}
}
},
"search_results": {
"label": "Resultados de Busqueda (ROI APU Search)",
"priority": 73,
"fields": {
"search_ads_enabled": {
"type": "boolean",
"label": "Activar ads en busqueda",
"default": false,
"editable": true,
"description": "Insertar anuncios en resultados del buscador APU"
},
"search_top_ad_enabled": {
"type": "boolean",
"label": "Anuncio fijo arriba",
"default": true,
"editable": true,
"description": "Mostrar anuncio debajo del campo de busqueda"
},
"search_top_ad_format": {
"type": "select",
"label": "Formato anuncio superior",
"default": "auto",
"editable": true,
"options": ["auto", "display", "in-article"]
},
"search_between_enabled": {
"type": "boolean",
"label": "Anuncios entre resultados",
"default": true,
"editable": true
},
"search_between_max": {
"type": "select",
"label": "Maximo anuncios entre resultados",
"default": "1",
"editable": true,
"options": ["1", "2", "3"],
"description": "Maximo 3 por politicas AdSense"
},
"search_between_format": {
"type": "select",
"label": "Formato entre resultados",
"default": "in-article",
"editable": true,
"options": ["auto", "in-article", "autorelaxed"]
},
"search_between_position": {
"type": "select",
"label": "Posicion de anuncios",
"default": "random",
"editable": true,
"options": {
"random": "Aleatorio",
"fixed": "Fijo (cada N resultados)",
"first_half": "Primera mitad"
}
},
"search_between_every": {
"type": "select",
"label": "Cada N resultados (si es fijo)",
"default": "5",
"editable": true,
"options": ["3", "4", "5", "6", "7", "8", "10"]
}
}
},
"layout": {
"label": "Ubicaciones Archivos/Globales",
"priority": 80,

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -27,14 +27,6 @@
"default": false,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -29,20 +29,6 @@
"editable": true,
"description": "Muestra el botón en pantallas móviles (<992px). Por defecto oculto para ahorrar espacio en navbar móvil"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"required": true,
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas"
},
"description": "Define en qué páginas se mostrará el botón"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -30,19 +30,6 @@
"editable": true,
"required": true,
"description": "Muestra la imagen en dispositivos moviles (<768px)"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"required": true,
"options": {
"all": "Todas las paginas",
"posts": "Solo posts individuales",
"pages": "Solo paginas"
},
"description": "Define en que tipo de contenido se muestra la imagen"
}
}
},

View File

@@ -31,20 +31,6 @@
"required": true,
"description": "Muestra el hero en dispositivos móviles (<768px)"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"required": true,
"options": {
"all": "Todas las páginas",
"posts": "Solo posts individuales",
"pages": "Solo páginas",
"home": "Solo página de inicio"
},
"description": "Define en qué tipo de contenido se mostrará el hero"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",

View File

@@ -29,19 +29,6 @@
"editable": true,
"description": "Muestra el menú en dispositivos de escritorio (≥768px)"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas"
},
"description": "Define en qué páginas se muestra el navbar"
},
"sticky_enabled": {
"type": "boolean",
"label": "Navbar fijo (sticky)",

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -28,14 +28,6 @@
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",

View File

@@ -15,20 +15,6 @@
"required": true,
"description": "Activa o desactiva la barra de notificación superior"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"required": true,
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas"
},
"description": "Define en qué páginas se mostrará la barra"
},
"show_on_desktop": {
"type": "boolean",
"label": "Mostrar en desktop",

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility;
use ROITheme\Shared\Application\UseCases\EvaluatePageVisibility\EvaluatePageVisibilityUseCase;
use ROITheme\Shared\Application\UseCases\EvaluateExclusions\EvaluateExclusionsUseCase;
/**
* Caso de uso: Evaluar visibilidad completa de un componente
*
* Orquesta la evaluacion de:
* 1. Visibilidad por tipo de pagina (Plan 99.10)
* 2. Reglas de exclusion (Plan 99.11)
*
* El componente se muestra SOLO si:
* - Pasa la verificacion de tipo de pagina
* - NO esta excluido por ninguna regla
*
* PATRON: Facade/Orchestrator - combina dos UseCases
*
* @package ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility
*/
final class EvaluateComponentVisibilityUseCase
{
public function __construct(
private readonly EvaluatePageVisibilityUseCase $pageVisibilityUseCase,
private readonly EvaluateExclusionsUseCase $exclusionsUseCase
) {}
/**
* Evalua si el componente debe mostrarse en la pagina actual
*
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si debe mostrarse
*/
public function execute(string $componentName): bool
{
// Paso 1: Verificar visibilidad por tipo de pagina
$visibleByPageType = $this->pageVisibilityUseCase->execute($componentName);
if (!$visibleByPageType) {
return false;
}
// Paso 2: Verificar exclusiones
$isExcluded = $this->exclusionsUseCase->execute($componentName);
// Mostrar si NO esta excluido
return !$isExcluded;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\EvaluateExclusions;
use ROITheme\Shared\Domain\Contracts\ExclusionRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\PageContextProviderInterface;
/**
* Caso de uso: Evaluar si un componente debe excluirse en la pagina actual
*
* Obtiene las reglas de exclusion del repositorio y evalua si aplican
* al contexto actual (post ID, categorias, URL).
*
* DIP: Depende de interfaces, no implementaciones.
*
* @package ROITheme\Shared\Application\UseCases\EvaluateExclusions
*/
final class EvaluateExclusionsUseCase
{
public function __construct(
private readonly ExclusionRepositoryInterface $exclusionRepository,
private readonly PageContextProviderInterface $contextProvider
) {}
/**
* Evalua si el componente debe excluirse
*
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si debe EXCLUIRSE (NO mostrar)
*/
public function execute(string $componentName): bool
{
$exclusions = $this->exclusionRepository->getExclusions($componentName);
if (!$exclusions->isEnabled()) {
return false;
}
$context = $this->contextProvider->getCurrentContext();
return $exclusions->shouldExclude($context);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\EvaluatePageVisibility;
use ROITheme\Shared\Domain\Contracts\PageTypeDetectorInterface;
use ROITheme\Shared\Domain\Contracts\PageVisibilityRepositoryInterface;
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
/**
* Caso de uso: Evaluar si un componente debe mostrarse en la página actual
*
* @package ROITheme\Shared\Application\UseCases\EvaluatePageVisibility
*/
final class EvaluatePageVisibilityUseCase
{
// NOTA: Usa VisibilityDefaults::DEFAULT_VISIBILITY para cumplir DRY
public function __construct(
private readonly PageTypeDetectorInterface $pageTypeDetector,
private readonly PageVisibilityRepositoryInterface $visibilityRepository
) {}
/**
* Evalúa si el componente debe mostrarse en la página actual
*/
public function execute(string $componentName): bool
{
$config = $this->visibilityRepository->getVisibilityConfig($componentName);
if (empty($config)) {
// Usar constante compartida (DRY)
$config = VisibilityDefaults::DEFAULT_VISIBILITY;
}
$pageType = $this->pageTypeDetector->detect();
$visibilityField = $pageType->toVisibilityField();
return $this->toBool($config[$visibilityField] ?? true);
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Constants;
/**
* Constantes de exclusion por defecto para componentes
*
* @package ROITheme\Shared\Domain\Constants
*/
final class ExclusionDefaults
{
/**
* Configuracion de exclusion por defecto (sin exclusiones)
*/
public const DEFAULT_EXCLUSIONS = [
'exclusions_enabled' => false,
'exclude_categories' => '[]',
'exclude_post_ids' => '[]',
'exclude_url_patterns' => '[]',
];
/**
* Lista de campos de exclusion validos
*/
public const EXCLUSION_FIELDS = [
'exclusions_enabled',
'exclude_categories',
'exclude_post_ids',
'exclude_url_patterns',
];
/**
* Nombre del grupo en BD
*/
public const GROUP_NAME = '_exclusions';
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Constants;
/**
* Constantes de visibilidad por defecto para componentes
*
* Centraliza los valores por defecto para cumplir con DRY.
* Usado por:
* - EvaluatePageVisibilityUseCase (cuando no hay config en BD)
* - MigratePageVisibilityService (para crear registros iniciales)
*
* @package ROITheme\Shared\Domain\Constants
*/
final class VisibilityDefaults
{
/**
* Configuración de visibilidad por defecto para nuevos componentes
*
* - Home: SÍ mostrar (página principal)
* - Posts: SÍ mostrar (artículos del blog)
* - Pages: SÍ mostrar (páginas estáticas)
* - Archives: NO mostrar (listados de categorías/tags)
* - Search: NO mostrar (resultados de búsqueda)
*/
public const DEFAULT_VISIBILITY = [
'show_on_home' => true,
'show_on_posts' => true,
'show_on_pages' => true,
'show_on_archives' => false,
'show_on_search' => false,
];
/**
* Lista de campos de visibilidad válidos
*/
public const VISIBILITY_FIELDS = [
'show_on_home',
'show_on_posts',
'show_on_pages',
'show_on_archives',
'show_on_search',
];
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
use ROITheme\Shared\Domain\ValueObjects\ExclusionRuleSet;
/**
* Contrato para acceder a la configuracion de exclusiones
*
* Metodos: 3 (cumple ISP < 5 metodos)
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface ExclusionRepositoryInterface
{
/**
* Obtiene las exclusiones configuradas para un componente
*
* @param string $componentName Nombre del componente (kebab-case)
* @return ExclusionRuleSet Configuracion de exclusiones
*/
public function getExclusions(string $componentName): ExclusionRuleSet;
/**
* Guarda la configuracion de exclusiones de un componente
*
* @param ExclusionRuleSet $exclusions Configuracion a guardar
*/
public function saveExclusions(ExclusionRuleSet $exclusions): void;
/**
* Verifica si existe configuracion de exclusiones para un componente
*/
public function hasExclusions(string $componentName): bool;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Contrato para obtener el contexto de la pagina actual
*
* Abstrae la obtencion de datos del contexto actual (WordPress).
* Permite testear UseCases sin dependencia de WordPress.
*
* v1.1: Renombrado de ExclusionEvaluatorInterface (nombre semantico incorrecto)
* El nombre refleja que PROVEE contexto, no que EVALUA.
*
* Metodos: 1 (cumple ISP < 5 metodos)
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PageContextProviderInterface
{
/**
* Obtiene el contexto actual para evaluacion de exclusiones
*
* @return array{
* post_id: int,
* categories: array<array{term_id: int, slug: string, name: string}>,
* url: string,
* request_uri: string,
* post_type: string
* }
*/
public function getCurrentContext(): array;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
use ROITheme\Shared\Domain\ValueObjects\PageType;
/**
* Contrato para detectar el tipo de página actual
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PageTypeDetectorInterface
{
/**
* Detecta y retorna el tipo de página actual
*/
public function detect(): PageType;
public function isHome(): bool;
public function isPost(): bool;
public function isPage(): bool;
public function isArchive(): bool;
public function isSearch(): bool;
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Contrato para acceder a la configuración de visibilidad por página
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PageVisibilityRepositoryInterface
{
/**
* Obtiene la configuración de visibilidad de un componente
*
* @param string $componentName Nombre del componente (kebab-case)
* @return array<string, bool> Mapa de campo => habilitado
*/
public function getVisibilityConfig(string $componentName): array;
/**
* Guarda la configuración de visibilidad de un componente
*
* @param string $componentName Nombre del componente
* @param array<string, bool> $config Configuración a guardar
*/
public function saveVisibilityConfig(string $componentName, array $config): void;
/**
* Verifica si existe configuración de visibilidad para un componente
*/
public function hasVisibilityConfig(string $componentName): bool;
/**
* Obtiene lista de todos los componentes registrados
*
* @return array<string> Lista de nombres de componentes
*/
public function getAllComponentNames(): array;
/**
* Crea configuración de visibilidad por defecto para un componente
*
* @param string $componentName Nombre del componente
* @param array<string, bool> $defaults Valores por defecto
*/
public function createDefaultVisibility(string $componentName, array $defaults): void;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Contrato para obtener datos del request HTTP
*
* Encapsula el acceso a $_SERVER para:
* - Evitar acceso directo a superglobales en Infrastructure
* - Permitir testear sin dependencia de $_SERVER
*
* v1.1: Nuevo - encapsular acceso a $_SERVER
*
* Metodos: 1 (cumple ISP < 5 metodos)
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface ServerRequestProviderInterface
{
/**
* Obtiene el Request URI actual
*
* @return string URI del request (ej: "/blog/mi-post/")
*/
public function getRequestUri(): string;
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
/**
* Value Object: Exclusion por categoria
*
* Evalua si un post pertenece a alguna de las categorias excluidas.
* Soporta matching por slug o term_id.
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final class CategoryExclusion extends ExclusionRule
{
/**
* @param array<int|string> $excludedCategories Lista de slugs o IDs de categorias
*/
public function __construct(
private readonly array $excludedCategories = []
) {}
/**
* {@inheritdoc}
*
* Contexto esperado:
* - categories: array<array{term_id: int, slug: string, name: string}>
*/
public function matches(array $context): bool
{
if (!$this->hasValues()) {
return false;
}
$postCategories = $context['categories'] ?? [];
if (empty($postCategories)) {
return false;
}
foreach ($postCategories as $category) {
// Buscar por slug
if (in_array($category['slug'], $this->excludedCategories, true)) {
return true;
}
// Buscar por term_id
if (in_array($category['term_id'], $this->excludedCategories, true)) {
return true;
}
// Buscar por term_id como string (para comparaciones flexibles)
if (in_array((string) $category['term_id'], $this->excludedCategories, true)) {
return true;
}
}
return false;
}
public function hasValues(): bool
{
return !empty($this->excludedCategories);
}
public function serialize(): string
{
return json_encode($this->excludedCategories, JSON_UNESCAPED_UNICODE);
}
/**
* @return array<int|string>
*/
public function getExcludedCategories(): array
{
return $this->excludedCategories;
}
/**
* Crea instancia desde JSON
*/
public static function fromJson(string $json): self
{
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return self::empty();
}
return new self($decoded);
}
/**
* Crea instancia vacia
*/
public static function empty(): self
{
return new self([]);
}
}

View File

@@ -93,6 +93,12 @@ final readonly class ComponentConfiguration
'widget_3', // Widget 3 del footer (menú)
'newsletter', // Sección newsletter del footer
'footer_bottom', // Pie del footer (copyright)
// 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
];
/**

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
/**
* Clase base abstracta para reglas de exclusion
*
* Define el contrato comun para todos los tipos de exclusion.
* Cada implementacion concreta define su logica de matching.
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
abstract class ExclusionRule
{
/**
* Evalua si el contexto actual coincide con la regla
*
* @param array<string, mixed> $context Contexto de la pagina actual
* @return bool True si el contexto coincide (debe excluirse)
*/
abstract public function matches(array $context): bool;
/**
* Verifica si la regla tiene valores configurados
*
* @return bool True si hay valores configurados
*/
abstract public function hasValues(): bool;
/**
* Serializa los valores para almacenamiento
*
* @return string JSON string
*/
abstract public function serialize(): string;
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
/**
* Value Object Compuesto: Conjunto de reglas de exclusion
*
* Agrupa todas las reglas de exclusion para un componente.
* Evalua con logica OR (si cualquier regla coincide, se excluye).
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final class ExclusionRuleSet
{
public function __construct(
private readonly string $componentName,
private readonly bool $enabled,
private readonly CategoryExclusion $categoryExclusion,
private readonly PostIdExclusion $postIdExclusion,
private readonly UrlPatternExclusion $urlPatternExclusion
) {}
/**
* Evalua si el componente debe excluirse segun el contexto actual
*
* @param array<string, mixed> $context Contexto de la pagina actual
* @return bool True si debe excluirse (NO mostrar)
*/
public function shouldExclude(array $context): bool
{
if (!$this->enabled) {
return false;
}
// Evaluar cada tipo de exclusion (OR logico)
if ($this->categoryExclusion->matches($context)) {
return true;
}
if ($this->postIdExclusion->matches($context)) {
return true;
}
if ($this->urlPatternExclusion->matches($context)) {
return true;
}
return false;
}
/**
* Verifica si tiene alguna regla configurada
*/
public function hasAnyRule(): bool
{
return $this->categoryExclusion->hasValues()
|| $this->postIdExclusion->hasValues()
|| $this->urlPatternExclusion->hasValues();
}
public function getComponentName(): string
{
return $this->componentName;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function getCategoryExclusion(): CategoryExclusion
{
return $this->categoryExclusion;
}
public function getPostIdExclusion(): PostIdExclusion
{
return $this->postIdExclusion;
}
public function getUrlPatternExclusion(): UrlPatternExclusion
{
return $this->urlPatternExclusion;
}
/**
* Crea una instancia sin exclusiones (por defecto)
*/
public static function empty(string $componentName): self
{
return new self(
$componentName,
false,
CategoryExclusion::empty(),
PostIdExclusion::empty(),
UrlPatternExclusion::empty()
);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
/**
* Value Object que representa los tipos de página válidos
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final class PageType
{
public const HOME = 'home';
public const POST = 'post';
public const PAGE = 'page';
public const ARCHIVE = 'archive';
public const SEARCH = 'search';
public const UNKNOWN = 'unknown';
private const VALID_TYPES = [
self::HOME,
self::POST,
self::PAGE,
self::ARCHIVE,
self::SEARCH,
self::UNKNOWN,
];
private function __construct(
private readonly string $value
) {}
public static function fromString(string $type): self
{
if (!in_array($type, self::VALID_TYPES, true)) {
return new self(self::UNKNOWN);
}
return new self($type);
}
public static function home(): self
{
return new self(self::HOME);
}
public static function post(): self
{
return new self(self::POST);
}
public static function page(): self
{
return new self(self::PAGE);
}
public static function archive(): self
{
return new self(self::ARCHIVE);
}
public static function search(): self
{
return new self(self::SEARCH);
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Retorna el nombre del campo de visibilidad correspondiente
*/
public function toVisibilityField(): string
{
return match ($this->value) {
self::HOME => 'show_on_home',
self::POST => 'show_on_posts',
self::PAGE => 'show_on_pages',
self::ARCHIVE => 'show_on_archives',
self::SEARCH => 'show_on_search',
default => 'show_on_posts',
};
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
/**
* Value Object: Exclusion por ID de post/pagina
*
* Evalua si el post/pagina actual esta en la lista de IDs excluidos.
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final class PostIdExclusion extends ExclusionRule
{
/**
* @param array<int> $excludedPostIds Lista de IDs de posts/paginas
*/
public function __construct(
private readonly array $excludedPostIds = []
) {}
/**
* {@inheritdoc}
*
* Contexto esperado:
* - post_id: int
*/
public function matches(array $context): bool
{
if (!$this->hasValues()) {
return false;
}
$postId = $context['post_id'] ?? 0;
if ($postId === 0) {
return false;
}
return in_array($postId, $this->excludedPostIds, true);
}
public function hasValues(): bool
{
return !empty($this->excludedPostIds);
}
public function serialize(): string
{
return json_encode($this->excludedPostIds);
}
/**
* @return array<int>
*/
public function getExcludedPostIds(): array
{
return $this->excludedPostIds;
}
/**
* Crea instancia desde JSON
*/
public static function fromJson(string $json): self
{
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return self::empty();
}
// Asegurar que son enteros
$ids = array_map('intval', $decoded);
$ids = array_filter($ids, fn(int $id): bool => $id > 0);
return new self(array_values($ids));
}
/**
* Crea instancia vacia
*/
public static function empty(): self
{
return new self([]);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
/**
* Value Object: Exclusion por patron URL
*
* Evalua si la URL actual coincide con alguno de los patrones configurados.
* Soporta:
* - Substring simple: "/privado/" coincide con cualquier URL que contenga ese texto
* - Regex: Patrones que empiezan y terminan con "/" son evaluados como regex
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final class UrlPatternExclusion extends ExclusionRule
{
/**
* @param array<string> $urlPatterns Lista de patrones (substring o regex)
*/
public function __construct(
private readonly array $urlPatterns = []
) {}
/**
* {@inheritdoc}
*
* Contexto esperado:
* - request_uri: string (URI del request)
* - url: string (URL completa, opcional)
*/
public function matches(array $context): bool
{
if (!$this->hasValues()) {
return false;
}
$requestUri = $context['request_uri'] ?? '';
$url = $context['url'] ?? '';
if ($requestUri === '' && $url === '') {
return false;
}
foreach ($this->urlPatterns as $pattern) {
if ($this->matchesPattern($pattern, $requestUri, $url)) {
return true;
}
}
return false;
}
/**
* Evalua si un patron coincide con el request_uri o url
*/
private function matchesPattern(string $pattern, string $requestUri, string $url): bool
{
// Detectar si es regex (empieza con /)
if ($this->isRegex($pattern)) {
return $this->matchesRegex($pattern, $requestUri);
}
// Substring matching
return $this->matchesSubstring($pattern, $requestUri, $url);
}
/**
* Detecta si el patron es una expresion regular
*/
private function isRegex(string $pattern): bool
{
// Un patron regex debe empezar con / y terminar con / (posiblemente con flags)
return preg_match('#^/.+/[gimsux]*$#', $pattern) === 1;
}
/**
* Evalua coincidencia regex
*/
private function matchesRegex(string $pattern, string $subject): bool
{
// Suprimir warnings de regex invalidos
$result = @preg_match($pattern, $subject);
return $result === 1;
}
/**
* 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;
}
if ($url !== '' && str_contains($url, $pattern)) {
return true;
}
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);
}
public function serialize(): string
{
return json_encode($this->urlPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* @return array<string>
*/
public function getUrlPatterns(): array
{
return $this->urlPatterns;
}
/**
* Crea instancia desde JSON
*/
public static function fromJson(string $json): self
{
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return self::empty();
}
// Filtrar valores vacios
$patterns = array_filter($decoded, fn($p): bool => is_string($p) && $p !== '');
return new self(array_values($patterns));
}
/**
* Crea instancia vacia
*/
public static function empty(): self
{
return new self([]);
}
}

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Api\WordPress;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
/**
* WP-CLI Command para Sincronización de Schemas
*
@@ -297,6 +299,298 @@ final class MigrationCommand
'stats' => $stats
];
}
/**
* Migra configuración de visibilidad para todos los componentes
*
* ## EXAMPLES
*
* wp roi-theme migrate-visibility
*
* @when after_wp_load
*/
public function migrate_visibility(): void
{
$container = DIContainer::getInstance();
$service = $container->getMigratePageVisibilityService();
$result = $service->migrate();
\WP_CLI::success(sprintf(
'Migración completada: %d creados, %d omitidos',
$result['created'],
$result['skipped']
));
}
/**
* Shortcodes que DEBEN ser preservados
*/
private const PROTECTED_SHORTCODES = ['[roi_apu_search', '[roi_'];
/**
* Máximo porcentaje de contenido que puede eliminarse
*/
private const MAX_CONTENT_LOSS_PERCENT = 50;
/**
* Limpia contenido Thrive congelado de páginas (H2 y paginación)
*
* LIMPIEZA QUIRÚRGICA CON VALIDACIONES DE SEGURIDAD:
* - Elimina H2 con data-shortcode="tcb_post_title"
* - Elimina paginación rota ([tcb_pagination_current_page], [tcb_pagination_total_pages])
* - PRESERVA todo el demás contenido incluyendo shortcodes [roi_apu_search]
* - Verifica que shortcodes importantes NO sean eliminados
* - Aborta si se detecta pérdida excesiva de contenido (>50%)
*
* ## OPTIONS
*
* [--dry-run]
* : Mostrar qué se limpiaría sin modificar nada (OBLIGATORIO primero)
*
* [--force]
* : Ejecutar la limpieza real después de verificar dry-run
*
* [--include-others]
* : Incluir otras páginas afectadas (Blog, Curso)
*
* ## EXAMPLES
*
* # Ver qué se limpiaría (modo seguro) - SIEMPRE PRIMERO
* wp roi-theme clean_thrive --dry-run
*
* # Ejecutar limpieza real (requiere --force)
* wp roi-theme clean_thrive --force
*
* @when after_wp_load
*/
public function clean_thrive(array $args, array $assoc_args): void
{
$affectedPageIds = [
107264, 107312, 107340, 107345, 107351, 107357, 107362,
107369, 107374, 107379, 107384, 107389, 107395, 107399,
107403, 107407, 107411, 107416, 107421, 107425, 185752
];
$otherAffectedIds = [252030, 290709];
$dryRun = isset($assoc_args['dry-run']);
$includeOthers = isset($assoc_args['include-others']);
$force = isset($assoc_args['force']);
$pageIds = $affectedPageIds;
if ($includeOthers) {
$pageIds = array_merge($pageIds, $otherAffectedIds);
}
\WP_CLI::line('');
\WP_CLI::line('╔══════════════════════════════════════════════════════════════════╗');
\WP_CLI::line('║ LIMPIEZA QUIRÚRGICA DE CONTENIDO THRIVE CONGELADO (v2.0) ║');
\WP_CLI::line('║ Con validaciones de seguridad para proteger shortcodes ║');
\WP_CLI::line('╚══════════════════════════════════════════════════════════════════╝');
\WP_CLI::line('');
if ($dryRun) {
\WP_CLI::warning('MODO DRY-RUN: No se modificará ningún contenido');
} else {
\WP_CLI::error('MODO REAL DESHABILITADO: Ejecuta primero con --dry-run', false);
\WP_CLI::line('');
\WP_CLI::line('Para ejecutar la limpieza real, primero revisa el dry-run:');
\WP_CLI::line(' wp roi-theme clean_thrive --dry-run');
\WP_CLI::line('');
\WP_CLI::line('Si el dry-run es correcto y deseas ejecutar:');
\WP_CLI::line(' wp roi-theme clean_thrive --force');
if (!$force) {
return;
}
\WP_CLI::warning('MODO REAL CON --force: Se modificará el contenido');
}
\WP_CLI::line('');
\WP_CLI::line('Páginas a procesar: ' . count($pageIds));
\WP_CLI::line('Shortcodes protegidos: ' . implode(', ', self::PROTECTED_SHORTCODES));
\WP_CLI::line('Máxima pérdida permitida: ' . self::MAX_CONTENT_LOSS_PERCENT . '%');
\WP_CLI::line('');
$totalH2Removed = 0;
$totalPaginationRemoved = 0;
$totalBytesFreed = 0;
$pagesModified = 0;
$pagesSkipped = 0;
$errors = [];
foreach ($pageIds as $id) {
$page = get_post($id);
if (!$page) {
\WP_CLI::warning("Página {$id} no encontrada, saltando...");
continue;
}
$originalContent = $page->post_content;
$originalSize = strlen($originalContent);
$hasThrive = strpos($originalContent, 'tcb_post_title') !== false ||
strpos($originalContent, 'tcb_pagination') !== false;
if (!$hasThrive) {
\WP_CLI::line(sprintf("[SIN THRIVE] ID %d: %s", $id, mb_substr($page->post_title, 0, 50)));
continue;
}
$h2Count = preg_match_all('/<h2[^>]*>\s*<span[^>]*data-shortcode="tcb_post_title"[^>]*>.*?<\/span>\s*<\/h2>/s', $originalContent);
$protectedBefore = $this->countProtectedShortcodes($originalContent);
$cleanResult = $this->cleanThriveContentSafely($originalContent);
if ($cleanResult['error']) {
$errors[] = "ID {$id}: {$cleanResult['error']}";
\WP_CLI::error(sprintf("[ERROR] ID %d: %s - %s", $id, mb_substr($page->post_title, 0, 40), $cleanResult['error']), false);
$pagesSkipped++;
continue;
}
$cleanedContent = $cleanResult['content'];
$newSize = strlen($cleanedContent);
$protectedAfter = $this->countProtectedShortcodes($cleanedContent);
if ($protectedAfter < $protectedBefore) {
$errors[] = "ID {$id}: Se perderían shortcodes protegidos ({$protectedBefore}{$protectedAfter})";
\WP_CLI::error(sprintf("[ABORTADO] ID %d: Se perderían shortcodes protegidos (%d → %d)", $id, $protectedBefore, $protectedAfter), false);
$pagesSkipped++;
continue;
}
$lossPercent = $originalSize > 0 ? (($originalSize - $newSize) / $originalSize) * 100 : 0;
if ($lossPercent > self::MAX_CONTENT_LOSS_PERCENT) {
$errors[] = "ID {$id}: Pérdida excesiva de contenido ({$lossPercent}%)";
\WP_CLI::error(sprintf("[ABORTADO] ID %d: Pérdida excesiva %.1f%% (máx %d%%)", $id, $lossPercent, self::MAX_CONTENT_LOSS_PERCENT), false);
$pagesSkipped++;
continue;
}
$hasChanges = $originalContent !== $cleanedContent;
$bytesSaved = $originalSize - $newSize;
$paginationRemoved = (strpos($originalContent, 'tcb_pagination_current_page') !== false && strpos($cleanedContent, 'tcb_pagination_current_page') === false) ? 1 : 0;
if ($hasChanges) {
$pagesModified++;
$totalH2Removed += $h2Count;
$totalPaginationRemoved += $paginationRemoved;
$totalBytesFreed += $bytesSaved;
$status = $dryRun ? '[DRY-RUN]' : '[LIMPIADO]';
\WP_CLI::line(sprintf("%s ID %d: %s", $status, $id, mb_substr($page->post_title, 0, 50) . (mb_strlen($page->post_title) > 50 ? '...' : '')));
\WP_CLI::line(sprintf(" → H2 eliminados: %d | Paginación: %s | Pérdida: %.1f%%", $h2Count, $paginationRemoved ? 'Sí' : 'No', $lossPercent));
\WP_CLI::line(sprintf(" → Shortcodes [roi_*] preservados: %d | Bytes liberados: %s", $protectedAfter, $this->formatBytes($bytesSaved)));
if (!$dryRun && $force) {
wp_update_post(['ID' => $id, 'post_content' => $cleanedContent]);
}
} else {
\WP_CLI::line(sprintf("[SIN CAMBIOS] ID %d: %s", $id, mb_substr($page->post_title, 0, 50)));
}
}
\WP_CLI::line('');
\WP_CLI::line('════════════════════════════════════════════════════════════════════');
\WP_CLI::line('RESUMEN:');
\WP_CLI::line(sprintf(' Páginas modificadas: %d', $pagesModified));
\WP_CLI::line(sprintf(' Páginas omitidas: %d', $pagesSkipped));
\WP_CLI::line(sprintf(' Total H2 eliminados: %d', $totalH2Removed));
\WP_CLI::line(sprintf(' Paginaciones removidas: %d', $totalPaginationRemoved));
\WP_CLI::line(sprintf(' Espacio liberado: %s', $this->formatBytes($totalBytesFreed)));
\WP_CLI::line('════════════════════════════════════════════════════════════════════');
if (count($errors) > 0) {
\WP_CLI::line('');
\WP_CLI::warning('ERRORES ENCONTRADOS:');
foreach ($errors as $error) {
\WP_CLI::line(" - {$error}");
}
}
if ($dryRun && $pagesModified > 0 && count($errors) === 0) {
\WP_CLI::line('');
\WP_CLI::success('Dry-run completado SIN errores.');
\WP_CLI::line('');
\WP_CLI::warning('Para ejecutar la limpieza real:');
\WP_CLI::line(' wp roi-theme clean_thrive --force');
} elseif (!$dryRun && $force && $pagesModified > 0) {
\WP_CLI::line('');
\WP_CLI::success('Limpieza completada exitosamente.');
\WP_CLI::line('');
\WP_CLI::warning('IMPORTANTE: Purga el caché del sitio para ver los cambios.');
}
}
/**
* Limpia el contenido con validaciones de seguridad
* @return array{content: string, error: string|null}
*/
private function cleanThriveContentSafely(string $content): array
{
$originalContent = $content;
// Patrón específico: H2 que contiene span con data-shortcode="tcb_post_title"
// Estructura: <h2><span data-shortcode="tcb_post_title"...>...</span></h2>
$result = preg_replace('/<h2[^>]*>\s*<span[^>]*data-shortcode="tcb_post_title"[^>]*>.*?<\/span>\s*<\/h2>/s', '', $content);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón H2'];
}
$content = $result;
$result = preg_replace('/<p[^>]*>.*?\[tcb_pagination_current_page\].*?\[tcb_pagination_total_pages\].*?<\/p>/s', '', $content);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón paginación'];
}
$content = $result;
$result = preg_replace('/<p[^>]*data-button_layout="[^"]*"[^>]*data-page="[^"]*"[^>]*>.*?<\/p>/s', '', $content);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón botones'];
}
$content = $result;
$content = str_replace('[tcb_pagination_current_page]', '', $content);
$content = str_replace('[tcb_pagination_total_pages]', '', $content);
$result = preg_replace('/(\r?\n){3,}/', "\n\n", $content);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en limpieza líneas'];
}
$content = trim($result);
if (empty($content) && !empty($originalContent)) {
return ['content' => $originalContent, 'error' => 'El contenido quedó vacío'];
}
return ['content' => $content, 'error' => null];
}
/**
* Cuenta shortcodes protegidos en el contenido
*/
private function countProtectedShortcodes(string $content): int
{
$count = 0;
foreach (self::PROTECTED_SHORTCODES as $shortcode) {
$count += substr_count($content, $shortcode);
}
return $count;
}
/**
* Formatea bytes a formato legible
*/
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
} elseif ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
} else {
return round($bytes / 1048576, 2) . ' MB';
}
}
}
// Registrar comando WP-CLI

View File

@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\CLI;
use WP_CLI;
/**
* Comando WP-CLI para limpiar contenido Thrive congelado de páginas
*
* LIMPIEZA QUIRÚRGICA CON VALIDACIONES DE SEGURIDAD:
* - Elimina H2 con data-shortcode="tcb_post_title"
* - Elimina paginación rota ([tcb_pagination_current_page], [tcb_pagination_total_pages])
* - PRESERVA todo el demás contenido incluyendo shortcodes [roi_apu_search]
* - Verifica que shortcodes importantes NO sean eliminados
* - Aborta si se detecta pérdida excesiva de contenido (>50%)
*
* USO:
* wp roi-theme clean_thrive --dry-run # Ver qué se limpiaría (OBLIGATORIO primero)
* wp roi-theme clean_thrive # Ejecutar limpieza real
*
* SEGURIDAD:
* - Verifica preservación de shortcodes [roi_apu_search]
* - Máximo 50% de reducción de contenido permitida
* - Valida cada preg_replace para evitar null returns
*/
final class CleanThriveContentCommand
{
/**
* IDs de páginas buscar-apus afectadas
*/
private const AFFECTED_PAGE_IDS = [
107264, 107312, 107340, 107345, 107351, 107357, 107362,
107369, 107374, 107379, 107384, 107389, 107395, 107399,
107403, 107407, 107411, 107416, 107421, 107425, 185752
];
/**
* Otras páginas con contenido Thrive (Blog, Curso)
*/
private const OTHER_AFFECTED_IDS = [252030, 290709];
/**
* Shortcodes que DEBEN ser preservados
*/
private const PROTECTED_SHORTCODES = [
'[roi_apu_search',
'[roi_',
];
/**
* Máximo porcentaje de contenido que puede eliminarse
*/
private const MAX_CONTENT_LOSS_PERCENT = 50;
public function __invoke(array $args, array $assoc_args): void
{
$dryRun = isset($assoc_args['dry-run']);
$includeOthers = isset($assoc_args['include-others']);
$force = isset($assoc_args['force']);
$pageIds = self::AFFECTED_PAGE_IDS;
if ($includeOthers) {
$pageIds = array_merge($pageIds, self::OTHER_AFFECTED_IDS);
}
WP_CLI::log('');
WP_CLI::log('╔══════════════════════════════════════════════════════════════════╗');
WP_CLI::log('║ LIMPIEZA QUIRÚRGICA DE CONTENIDO THRIVE CONGELADO (v2.0) ║');
WP_CLI::log('║ Con validaciones de seguridad para proteger shortcodes ║');
WP_CLI::log('╚══════════════════════════════════════════════════════════════════╝');
WP_CLI::log('');
if ($dryRun) {
WP_CLI::warning('MODO DRY-RUN: No se modificará ningún contenido');
} else {
WP_CLI::error('MODO REAL DESHABILITADO: Ejecuta primero con --dry-run', false);
WP_CLI::log('');
WP_CLI::log('Para ejecutar la limpieza real, primero revisa el dry-run:');
WP_CLI::log(' wp roi-theme clean_thrive --dry-run');
WP_CLI::log('');
WP_CLI::log('Si el dry-run es correcto y deseas ejecutar:');
WP_CLI::log(' wp roi-theme clean_thrive --force');
if (!$force) {
return;
}
WP_CLI::warning('MODO REAL CON --force: Se modificará el contenido');
}
WP_CLI::log('');
WP_CLI::log('Páginas a procesar: ' . count($pageIds));
WP_CLI::log('Shortcodes protegidos: ' . implode(', ', self::PROTECTED_SHORTCODES));
WP_CLI::log('Máxima pérdida permitida: ' . self::MAX_CONTENT_LOSS_PERCENT . '%');
WP_CLI::log('');
$totalH2Removed = 0;
$totalPaginationRemoved = 0;
$totalBytesFreed = 0;
$pagesModified = 0;
$pagesSkipped = 0;
$errors = [];
foreach ($pageIds as $id) {
$page = get_post($id);
if (!$page) {
WP_CLI::warning("Página {$id} no encontrada, saltando...");
continue;
}
$originalContent = $page->post_content;
$originalSize = strlen($originalContent);
// Verificar si tiene contenido Thrive que limpiar
$hasThrive = strpos($originalContent, 'tcb_post_title') !== false ||
strpos($originalContent, 'tcb_pagination') !== false;
if (!$hasThrive) {
WP_CLI::log(sprintf(
"[SIN THRIVE] ID %d: %s",
$id,
mb_substr($page->post_title, 0, 50)
));
continue;
}
// Contar elementos antes de limpiar
$h2Count = preg_match_all('/<h2[^>]*>.*?data-shortcode="tcb_post_title".*?<\/h2>/s', $originalContent);
// Contar shortcodes protegidos antes
$protectedBefore = $this->countProtectedShortcodes($originalContent);
// Limpiar contenido con validación
$cleanResult = $this->cleanContentSafely($originalContent);
if ($cleanResult['error']) {
$errors[] = "ID {$id}: {$cleanResult['error']}";
WP_CLI::error(sprintf(
"[ERROR] ID %d: %s - %s",
$id,
mb_substr($page->post_title, 0, 40),
$cleanResult['error']
), false);
$pagesSkipped++;
continue;
}
$cleanedContent = $cleanResult['content'];
$newSize = strlen($cleanedContent);
// Contar shortcodes protegidos después
$protectedAfter = $this->countProtectedShortcodes($cleanedContent);
// VALIDACIÓN CRÍTICA: Verificar shortcodes protegidos
if ($protectedAfter < $protectedBefore) {
$errors[] = "ID {$id}: Se perderían shortcodes protegidos ({$protectedBefore}{$protectedAfter})";
WP_CLI::error(sprintf(
"[ABORTADO] ID %d: Se perderían shortcodes protegidos (%d → %d)",
$id,
$protectedBefore,
$protectedAfter
), false);
$pagesSkipped++;
continue;
}
// Verificar pérdida excesiva de contenido
$lossPercent = $originalSize > 0 ? (($originalSize - $newSize) / $originalSize) * 100 : 0;
if ($lossPercent > self::MAX_CONTENT_LOSS_PERCENT) {
$errors[] = "ID {$id}: Pérdida excesiva de contenido ({$lossPercent}%)";
WP_CLI::error(sprintf(
"[ABORTADO] ID %d: Pérdida excesiva %.1f%% (máx %d%%)",
$id,
$lossPercent,
self::MAX_CONTENT_LOSS_PERCENT
), false);
$pagesSkipped++;
continue;
}
// Verificar si hubo cambios
$hasChanges = $originalContent !== $cleanedContent;
$bytesSaved = $originalSize - $newSize;
// Contar paginación removida
$paginationRemoved = (
strpos($originalContent, 'tcb_pagination_current_page') !== false &&
strpos($cleanedContent, 'tcb_pagination_current_page') === false
) ? 1 : 0;
if ($hasChanges) {
$pagesModified++;
$totalH2Removed += $h2Count;
$totalPaginationRemoved += $paginationRemoved;
$totalBytesFreed += $bytesSaved;
$status = $dryRun ? '[DRY-RUN]' : '[LIMPIADO]';
WP_CLI::log(sprintf(
"%s ID %d: %s",
$status,
$id,
mb_substr($page->post_title, 0, 50) . (mb_strlen($page->post_title) > 50 ? '...' : '')
));
WP_CLI::log(sprintf(
" → H2 eliminados: %d | Paginación: %s | Pérdida: %.1f%%",
$h2Count,
$paginationRemoved ? 'Sí' : 'No',
$lossPercent
));
WP_CLI::log(sprintf(
" → Shortcodes [roi_*] preservados: %d | Bytes liberados: %s",
$protectedAfter,
$this->formatBytes($bytesSaved)
));
if (!$dryRun && $force) {
wp_update_post([
'ID' => $id,
'post_content' => $cleanedContent
]);
}
} else {
WP_CLI::log(sprintf(
"[SIN CAMBIOS] ID %d: %s",
$id,
mb_substr($page->post_title, 0, 50)
));
}
}
WP_CLI::log('');
WP_CLI::log('════════════════════════════════════════════════════════════════════');
WP_CLI::log('RESUMEN:');
WP_CLI::log(sprintf(' Páginas modificadas: %d', $pagesModified));
WP_CLI::log(sprintf(' Páginas omitidas: %d', $pagesSkipped));
WP_CLI::log(sprintf(' Total H2 eliminados: %d', $totalH2Removed));
WP_CLI::log(sprintf(' Paginaciones removidas: %d', $totalPaginationRemoved));
WP_CLI::log(sprintf(' Espacio liberado: %s', $this->formatBytes($totalBytesFreed)));
WP_CLI::log('════════════════════════════════════════════════════════════════════');
if (count($errors) > 0) {
WP_CLI::log('');
WP_CLI::warning('ERRORES ENCONTRADOS:');
foreach ($errors as $error) {
WP_CLI::log(" - {$error}");
}
}
if ($dryRun && $pagesModified > 0 && count($errors) === 0) {
WP_CLI::log('');
WP_CLI::success('Dry-run completado SIN errores.');
WP_CLI::log('');
WP_CLI::warning('Para ejecutar la limpieza real:');
WP_CLI::log(' wp roi-theme clean_thrive --force');
} elseif (!$dryRun && $force && $pagesModified > 0) {
WP_CLI::log('');
WP_CLI::success('Limpieza completada exitosamente.');
WP_CLI::log('');
WP_CLI::warning('IMPORTANTE: Purga el caché del sitio para ver los cambios.');
}
}
/**
* Limpia el contenido con validaciones de seguridad
*
* @return array{content: string, error: string|null}
*/
private function cleanContentSafely(string $content): array
{
$originalContent = $content;
// 1. Eliminar H2 con data-shortcode="tcb_post_title"
$result = preg_replace(
'/<h2[^>]*>.*?data-shortcode="tcb_post_title".*?<\/h2>/s',
'',
$content
);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón H2'];
}
$content = $result;
// 2. Eliminar paginación Thrive rota
$result = preg_replace(
'/<p[^>]*>.*?\[tcb_pagination_current_page\].*?\[tcb_pagination_total_pages\].*?<\/p>/s',
'',
$content
);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón paginación'];
}
$content = $result;
// 3. Eliminar botones de paginación Thrive
$result = preg_replace(
'/<p[^>]*data-button_layout="[^"]*"[^>]*data-page="[^"]*"[^>]*>.*?<\/p>/s',
'',
$content
);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón botones'];
}
$content = $result;
// 4. Eliminar shortcodes Thrive huérfanos
$content = str_replace('[tcb_pagination_current_page]', '', $content);
$content = str_replace('[tcb_pagination_total_pages]', '', $content);
// 5. Limpiar múltiples líneas vacías (con validación)
$result = preg_replace('/(\r?\n){3,}/', "\n\n", $content);
if ($result === null) {
return ['content' => $originalContent, 'error' => 'preg_replace falló en limpieza líneas'];
}
$content = $result;
// 6. Trim
$content = trim($content);
// Validación final: no retornar vacío si original tenía contenido
if (empty($content) && !empty($originalContent)) {
return ['content' => $originalContent, 'error' => 'El contenido quedó vacío'];
}
return ['content' => $content, 'error' => null];
}
/**
* Cuenta shortcodes protegidos en el contenido
*/
private function countProtectedShortcodes(string $content): int
{
$count = 0;
foreach (self::PROTECTED_SHORTCODES as $shortcode) {
$count += substr_count($content, $shortcode);
}
return $count;
}
/**
* Formatea bytes a formato legible
*/
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
} elseif ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
} else {
return round($bytes / 1048576, 2) . ' MB';
}
}
}

View File

@@ -22,6 +22,21 @@ use ROITheme\Shared\Infrastructure\Services\CriticalCSSCollector;
use ROITheme\Shared\Application\UseCases\GetComponentSettings\GetComponentSettingsUseCase;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
use ROITheme\Shared\Domain\Contracts\PageVisibilityRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\PageTypeDetectorInterface;
use ROITheme\Shared\Infrastructure\Services\WordPressPageTypeDetector;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressPageVisibilityRepository;
use ROITheme\Shared\Application\UseCases\EvaluatePageVisibility\EvaluatePageVisibilityUseCase;
use ROITheme\Shared\Infrastructure\Services\MigratePageVisibilityService;
// Exclusion System (Plan 99.11)
use ROITheme\Shared\Domain\Contracts\ExclusionRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\PageContextProviderInterface;
use ROITheme\Shared\Domain\Contracts\ServerRequestProviderInterface;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressExclusionRepository;
use ROITheme\Shared\Infrastructure\Services\WordPressPageContextProvider;
use ROITheme\Shared\Infrastructure\Services\WordPressServerRequestProvider;
use ROITheme\Shared\Application\UseCases\EvaluateExclusions\EvaluateExclusionsUseCase;
use ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility\EvaluateComponentVisibilityUseCase;
/**
* DIContainer - Contenedor de Inyección de Dependencias
@@ -46,10 +61,38 @@ final class DIContainer
{
private array $instances = [];
/**
* Instancia singleton del contenedor
* @var self|null
*/
private static ?self $instance = null;
/**
* Obtiene la instancia singleton del contenedor
*
* NOTA: Se debe haber creado una instancia previamente en functions.php
* El constructor registra automáticamente la instancia.
*
* @return self
* @throws \RuntimeException Si no se ha inicializado el contenedor
*/
public static function getInstance(): self
{
if (self::$instance === null) {
throw new \RuntimeException(
'DIContainer no ha sido inicializado. Asegúrate de que functions.php se haya ejecutado primero.'
);
}
return self::$instance;
}
public function __construct(
private \wpdb $wpdb,
private string $schemasPath
) {}
) {
// Registrar como instancia singleton
self::$instance = $this;
}
/**
* Obtener repositorio de componentes
@@ -272,4 +315,132 @@ final class DIContainer
return $this->instances['criticalCSSCollector'];
}
// ===============================
// Page Visibility System
// ===============================
/**
* Obtiene el repositorio de visibilidad de página
*
* IMPORTANTE: Inyecta $wpdb para consistencia con el resto del código
* (WordPressComponentSettingsRepository también recibe $wpdb por constructor)
*/
public function getPageVisibilityRepository(): PageVisibilityRepositoryInterface
{
if (!isset($this->instances['pageVisibilityRepository'])) {
// Inyectar $wpdb siguiendo el patrón existente
$this->instances['pageVisibilityRepository'] = new WordPressPageVisibilityRepository($this->wpdb);
}
return $this->instances['pageVisibilityRepository'];
}
/**
* Obtiene el detector de tipo de página
*/
public function getPageTypeDetector(): PageTypeDetectorInterface
{
if (!isset($this->instances['pageTypeDetector'])) {
$this->instances['pageTypeDetector'] = new WordPressPageTypeDetector();
}
return $this->instances['pageTypeDetector'];
}
/**
* Obtiene el caso de uso de evaluación de visibilidad
*/
public function getEvaluatePageVisibilityUseCase(): EvaluatePageVisibilityUseCase
{
if (!isset($this->instances['evaluatePageVisibilityUseCase'])) {
$this->instances['evaluatePageVisibilityUseCase'] = new EvaluatePageVisibilityUseCase(
$this->getPageTypeDetector(),
$this->getPageVisibilityRepository()
);
}
return $this->instances['evaluatePageVisibilityUseCase'];
}
/**
* Obtiene el servicio de migración de visibilidad
*/
public function getMigratePageVisibilityService(): MigratePageVisibilityService
{
if (!isset($this->instances['migratePageVisibilityService'])) {
$this->instances['migratePageVisibilityService'] = new MigratePageVisibilityService(
$this->getPageVisibilityRepository()
);
}
return $this->instances['migratePageVisibilityService'];
}
// ===============================
// Exclusion System (Plan 99.11)
// ===============================
/**
* Obtiene el proveedor de request HTTP
*
* Encapsula acceso a $_SERVER
*/
public function getServerRequestProvider(): ServerRequestProviderInterface
{
if (!isset($this->instances['serverRequestProvider'])) {
$this->instances['serverRequestProvider'] = new WordPressServerRequestProvider();
}
return $this->instances['serverRequestProvider'];
}
/**
* Obtiene el repositorio de exclusiones
*/
public function getExclusionRepository(): ExclusionRepositoryInterface
{
if (!isset($this->instances['exclusionRepository'])) {
$this->instances['exclusionRepository'] = new WordPressExclusionRepository($this->wpdb);
}
return $this->instances['exclusionRepository'];
}
/**
* Obtiene el proveedor de contexto de página
*/
public function getPageContextProvider(): PageContextProviderInterface
{
if (!isset($this->instances['pageContextProvider'])) {
$this->instances['pageContextProvider'] = new WordPressPageContextProvider(
$this->getServerRequestProvider()
);
}
return $this->instances['pageContextProvider'];
}
/**
* Obtiene el caso de uso de evaluación de exclusiones
*/
public function getEvaluateExclusionsUseCase(): EvaluateExclusionsUseCase
{
if (!isset($this->instances['evaluateExclusionsUseCase'])) {
$this->instances['evaluateExclusionsUseCase'] = new EvaluateExclusionsUseCase(
$this->getExclusionRepository(),
$this->getPageContextProvider()
);
}
return $this->instances['evaluateExclusionsUseCase'];
}
/**
* Obtiene el caso de uso orquestador de visibilidad completa
*
* Combina visibilidad por tipo de página + exclusiones
*/
public function getEvaluateComponentVisibilityUseCase(): EvaluateComponentVisibilityUseCase
{
if (!isset($this->instances['evaluateComponentVisibilityUseCase'])) {
$this->instances['evaluateComponentVisibilityUseCase'] = new EvaluateComponentVisibilityUseCase(
$this->getEvaluatePageVisibilityUseCase(),
$this->getEvaluateExclusionsUseCase()
);
}
return $this->instances['evaluateComponentVisibilityUseCase'];
}
}

View File

@@ -109,13 +109,21 @@ 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
// Verificar si el registro existe
$exists = $this->fieldExists($componentName, $groupName, $attributeName);
if ($exists) {
// UPDATE
$result = $this->wpdb->update(
$this->tableName,
['attribute_value' => $serializedValue],
@@ -127,10 +135,41 @@ final class WordPressComponentSettingsRepository implements ComponentSettingsRep
['%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}
*/

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Persistence\WordPress;
use ROITheme\Shared\Domain\Contracts\ExclusionRepositoryInterface;
use ROITheme\Shared\Domain\ValueObjects\ExclusionRuleSet;
use ROITheme\Shared\Domain\ValueObjects\CategoryExclusion;
use ROITheme\Shared\Domain\ValueObjects\PostIdExclusion;
use ROITheme\Shared\Domain\ValueObjects\UrlPatternExclusion;
use ROITheme\Shared\Domain\Constants\ExclusionDefaults;
/**
* Implementacion WordPress del repositorio de exclusiones
*
* Almacena exclusiones en wp_roi_theme_component_settings
* con group_name = '_exclusions'
*
* @package ROITheme\Shared\Infrastructure\Persistence\WordPress
*/
final class WordPressExclusionRepository implements ExclusionRepositoryInterface
{
private const TABLE_SUFFIX = 'roi_theme_component_settings';
public function __construct(
private readonly \wpdb $wpdb
) {}
public function getExclusions(string $componentName): ExclusionRuleSet
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
$groupName = ExclusionDefaults::GROUP_NAME;
$results = $this->wpdb->get_results(
$this->wpdb->prepare(
"SELECT attribute_name, attribute_value
FROM {$table}
WHERE component_name = %s
AND group_name = %s",
$componentName,
$groupName
),
ARRAY_A
);
if (empty($results)) {
return ExclusionRuleSet::empty($componentName);
}
$data = [];
foreach ($results as $row) {
$data[$row['attribute_name']] = $row['attribute_value'];
}
return $this->hydrateExclusions($componentName, $data);
}
public function saveExclusions(ExclusionRuleSet $exclusions): void
{
$componentName = $exclusions->getComponentName();
$data = [
'exclusions_enabled' => $exclusions->isEnabled() ? '1' : '0',
'exclude_categories' => $exclusions->getCategoryExclusion()->serialize(),
'exclude_post_ids' => $exclusions->getPostIdExclusion()->serialize(),
'exclude_url_patterns' => $exclusions->getUrlPatternExclusion()->serialize(),
];
foreach ($data as $field => $value) {
$this->upsertField($componentName, $field, $value);
}
}
public function hasExclusions(string $componentName): bool
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
$groupName = ExclusionDefaults::GROUP_NAME;
$count = $this->wpdb->get_var($this->wpdb->prepare(
"SELECT COUNT(*) FROM {$table}
WHERE component_name = %s
AND group_name = %s",
$componentName,
$groupName
));
return (int) $count > 0;
}
private function hydrateExclusions(string $componentName, array $data): ExclusionRuleSet
{
$enabled = ($data['exclusions_enabled'] ?? '0') === '1';
$categoryExclusion = CategoryExclusion::fromJson($data['exclude_categories'] ?? '[]');
$postIdExclusion = PostIdExclusion::fromJson($data['exclude_post_ids'] ?? '[]');
$urlPatternExclusion = UrlPatternExclusion::fromJson($data['exclude_url_patterns'] ?? '[]');
return new ExclusionRuleSet(
$componentName,
$enabled,
$categoryExclusion,
$postIdExclusion,
$urlPatternExclusion
);
}
private function upsertField(string $componentName, string $field, string $value): void
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
$groupName = ExclusionDefaults::GROUP_NAME;
$exists = $this->wpdb->get_var($this->wpdb->prepare(
"SELECT COUNT(*) FROM {$table}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s",
$componentName,
$groupName,
$field
));
if ($exists) {
$this->wpdb->update(
$table,
[
'attribute_value' => $value,
'updated_at' => current_time('mysql'),
],
[
'component_name' => $componentName,
'group_name' => $groupName,
'attribute_name' => $field,
]
);
} else {
$this->wpdb->insert($table, [
'component_name' => $componentName,
'group_name' => $groupName,
'attribute_name' => $field,
'attribute_value' => $value,
'is_editable' => 1,
'created_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
]);
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Persistence\WordPress;
use ROITheme\Shared\Domain\Contracts\PageVisibilityRepositoryInterface;
/**
* Implementación WordPress del repositorio de visibilidad
*
* @package ROITheme\Shared\Infrastructure\Persistence\WordPress
*/
final class WordPressPageVisibilityRepository implements PageVisibilityRepositoryInterface
{
private const GROUP_NAME = '_page_visibility';
private const TABLE_SUFFIX = 'roi_theme_component_settings';
private const VISIBILITY_FIELDS = [
'show_on_home',
'show_on_posts',
'show_on_pages',
'show_on_archives',
'show_on_search',
];
/**
* Constructor con inyección de dependencias
*
* IMPORTANTE: Sigue el patrón existente de WordPressComponentSettingsRepository
* donde $wpdb se inyecta por constructor, no se usa global.
*/
public function __construct(
private readonly \wpdb $wpdb
) {}
public function getVisibilityConfig(string $componentName): array
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
$results = $this->wpdb->get_results(
$this->wpdb->prepare(
"SELECT attribute_name, attribute_value
FROM {$table}
WHERE component_name = %s
AND group_name = %s",
$componentName,
self::GROUP_NAME
),
ARRAY_A
);
if (empty($results)) {
return [];
}
$config = [];
foreach ($results as $row) {
$config[$row['attribute_name']] = $row['attribute_value'] === '1';
}
return $config;
}
public function saveVisibilityConfig(string $componentName, array $config): void
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
foreach ($config as $field => $enabled) {
if (!in_array($field, self::VISIBILITY_FIELDS, true)) {
continue;
}
$exists = $this->wpdb->get_var($this->wpdb->prepare(
"SELECT COUNT(*) FROM {$table}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s",
$componentName,
self::GROUP_NAME,
$field
));
$value = $enabled ? '1' : '0';
if ($exists) {
$this->wpdb->update(
$table,
[
'attribute_value' => $value,
'updated_at' => current_time('mysql'),
],
[
'component_name' => $componentName,
'group_name' => self::GROUP_NAME,
'attribute_name' => $field,
]
);
} else {
$this->wpdb->insert($table, [
'component_name' => $componentName,
'group_name' => self::GROUP_NAME,
'attribute_name' => $field,
'attribute_value' => $value,
'is_editable' => 1,
'created_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
]);
}
}
}
public function hasVisibilityConfig(string $componentName): bool
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
$count = $this->wpdb->get_var($this->wpdb->prepare(
"SELECT COUNT(*) FROM {$table}
WHERE component_name = %s
AND group_name = %s",
$componentName,
self::GROUP_NAME
));
return (int) $count > 0;
}
public function getAllComponentNames(): array
{
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
$results = $this->wpdb->get_col(
"SELECT DISTINCT component_name FROM {$table} ORDER BY component_name"
);
return $results ?: [];
}
public function createDefaultVisibility(string $componentName, array $defaults): void
{
if ($this->hasVisibilityConfig($componentName)) {
return;
}
$this->saveVisibilityConfig($componentName, $defaults);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\PageVisibilityRepositoryInterface;
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
/**
* Servicio para migrar configuración de visibilidad inicial
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class MigratePageVisibilityService
{
// NOTA: Usa VisibilityDefaults::DEFAULT_VISIBILITY para cumplir DRY
public function __construct(
private readonly PageVisibilityRepositoryInterface $visibilityRepository
) {}
/**
* Ejecuta la migración para todos los componentes
*
* @return array{created: int, skipped: int}
*/
public function migrate(): array
{
$created = 0;
$skipped = 0;
$components = $this->visibilityRepository->getAllComponentNames();
foreach ($components as $componentName) {
if ($this->visibilityRepository->hasVisibilityConfig($componentName)) {
$skipped++;
continue;
}
// Usar constante compartida (DRY)
$this->visibilityRepository->createDefaultVisibility(
$componentName,
VisibilityDefaults::DEFAULT_VISIBILITY
);
$created++;
}
return [
'created' => $created,
'skipped' => $skipped,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
/**
* Facade/Helper para evaluar visibilidad completa de componentes
*
* PROPOSITO:
* Permite que los Renderers existentes evaluen visibilidad sin modificar sus constructores.
* Ahora incluye tanto visibilidad por tipo de pagina como reglas de exclusion.
*
* USO EN RENDERERS:
* ```php
* if (!PageVisibilityHelper::shouldShow('cta-box-sidebar')) {
* return '';
* }
* ```
*
* FLUJO:
* 1. Verifica visibilidad por tipo de pagina (home, posts, pages, etc.)
* 2. Verifica reglas de exclusion (categorias, IDs, patrones URL)
* 3. Retorna true SOLO si pasa ambas verificaciones
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class PageVisibilityHelper
{
/**
* Evalua si un componente debe mostrarse en la pagina actual
*
* Incluye verificacion de:
* - Visibilidad por tipo de pagina (Plan 99.10)
* - Reglas de exclusion (Plan 99.11)
*
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si debe mostrarse
*/
public static function shouldShow(string $componentName): bool
{
$container = DIContainer::getInstance();
$useCase = $container->getEvaluateComponentVisibilityUseCase();
return $useCase->execute($componentName);
}
/**
* Evalua SOLO visibilidad por tipo de pagina (sin exclusiones)
*
* @deprecated Usar shouldShow() que incluye exclusiones
* @param string $componentName Nombre del componente (kebab-case)
* @return bool True si debe mostrarse segun tipo de pagina
*/
public static function shouldShowByPageType(string $componentName): bool
{
$container = DIContainer::getInstance();
$useCase = $container->getEvaluatePageVisibilityUseCase();
return $useCase->execute($componentName);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\PageContextProviderInterface;
use ROITheme\Shared\Domain\Contracts\ServerRequestProviderInterface;
/**
* Implementacion WordPress del proveedor de contexto de pagina
*
* Obtiene informacion del post/pagina actual usando funciones de WordPress.
*
* v1.1: Renombrado de WordPressExclusionEvaluator
* Inyecta ServerRequestProviderInterface (no accede a $_SERVER directamente)
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class WordPressPageContextProvider implements PageContextProviderInterface
{
public function __construct(
private readonly ServerRequestProviderInterface $requestProvider
) {}
public function getCurrentContext(): array
{
$postId = $this->getCurrentPostId();
return [
'post_id' => $postId,
'categories' => $this->getPostCategories($postId),
'url' => $this->getCurrentUrl(),
'request_uri' => $this->requestProvider->getRequestUri(),
'post_type' => $this->getCurrentPostType($postId),
];
}
private function getCurrentPostId(): int
{
if (is_singular()) {
return get_the_ID() ?: 0;
}
return 0;
}
/**
* @return array<array{term_id: int, slug: string, name: string}>
*/
private function getPostCategories(int $postId): array
{
if ($postId === 0) {
return [];
}
$categories = get_the_category($postId);
if (empty($categories)) {
return [];
}
return array_map(function (\WP_Term $term): array {
return [
'term_id' => $term->term_id,
'slug' => $term->slug,
'name' => $term->name,
];
}, $categories);
}
private function getCurrentUrl(): string
{
global $wp;
if (isset($wp->request)) {
return home_url($wp->request);
}
return home_url(add_query_arg([], false));
}
private function getCurrentPostType(int $postId): string
{
if ($postId === 0) {
return '';
}
return get_post_type($postId) ?: '';
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\PageTypeDetectorInterface;
use ROITheme\Shared\Domain\ValueObjects\PageType;
/**
* Implementación WordPress del detector de tipo de página
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class WordPressPageTypeDetector implements PageTypeDetectorInterface
{
public function detect(): PageType
{
if ($this->isHome()) {
return PageType::home();
}
if ($this->isPost()) {
return PageType::post();
}
if ($this->isPage()) {
return PageType::page();
}
if ($this->isSearch()) {
return PageType::search();
}
if ($this->isArchive()) {
return PageType::archive();
}
return PageType::fromString(PageType::UNKNOWN);
}
public function isHome(): bool
{
return is_front_page();
}
public function isPost(): bool
{
return is_single() && !is_front_page();
}
public function isPage(): bool
{
return is_page() && !is_front_page();
}
public function isArchive(): bool
{
return is_archive();
}
public function isSearch(): bool
{
return is_search();
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\ServerRequestProviderInterface;
/**
* Implementacion WordPress del proveedor de request HTTP
*
* Encapsula el acceso a $_SERVER.
*
* v1.1: Nuevo - extrae logica de acceso a superglobales
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class WordPressServerRequestProvider implements ServerRequestProviderInterface
{
public function getRequestUri(): string
{
return $_SERVER['REQUEST_URI'] ?? '';
}
}

324
build-bootstrap-subset.js Normal file
View File

@@ -0,0 +1,324 @@
/**
* Build Bootstrap Subset Script
*
* Genera un subset de Bootstrap con SOLO las clases usadas en el tema.
*
* USO:
* node build-bootstrap-subset.js
*
* OUTPUT:
* Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css
*/
const { PurgeCSS } = require('purgecss');
const { globSync } = require('glob');
const fs = require('fs');
const path = require('path');
async function buildBootstrapSubset() {
console.log('='.repeat(60));
console.log('Building Bootstrap Subset for ROI Theme');
console.log('='.repeat(60));
const themeDir = __dirname;
const inputFile = path.join(themeDir, 'Assets/Vendor/Bootstrap/Css/bootstrap.min.css');
const outputFile = path.join(themeDir, 'Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css');
// Verificar que existe el archivo de entrada
if (!fs.existsSync(inputFile)) {
console.error('ERROR: bootstrap.min.css not found at:', inputFile);
process.exit(1);
}
const inputSize = fs.statSync(inputFile).size;
console.log(`Input: bootstrap.min.css (${(inputSize / 1024).toFixed(2)} KB)`);
// Encontrar archivos PHP y JS manualmente
console.log('\nScanning for PHP and JS files...');
const patterns = [
'*.php',
'Public/**/*.php',
'Admin/**/*.php',
'Inc/**/*.php',
'Shared/**/*.php',
'template-parts/**/*.php',
'Assets/js/**/*.js',
];
let contentFiles = [];
for (const pattern of patterns) {
const files = globSync(pattern, { cwd: themeDir, absolute: true });
contentFiles = contentFiles.concat(files);
}
console.log(`Found ${contentFiles.length} files to analyze`);
if (contentFiles.length === 0) {
console.error('ERROR: No content files found. Check glob patterns.');
process.exit(1);
}
// Mostrar algunos archivos encontrados
console.log('\nSample files:');
contentFiles.slice(0, 5).forEach(f => console.log(' -', path.relative(themeDir, f)));
if (contentFiles.length > 5) {
console.log(` ... and ${contentFiles.length - 5} more`);
}
try {
const purgeCSSResult = await new PurgeCSS().purge({
css: [inputFile],
content: contentFiles,
// Safelist: Clases que SIEMPRE deben incluirse
safelist: {
standard: [
// Estados de navbar scroll (JavaScript)
'scrolled',
'navbar-scrolled',
// Bootstrap Collapse (JavaScript)
'show',
'showing',
'hiding',
'collapse',
'collapsing',
// Estados de dropdown
'dropdown-menu',
'dropdown-item',
'dropdown-toggle',
// Estados de form
'is-valid',
'is-invalid',
'was-validated',
// Visually hidden (accesibilidad)
'visually-hidden',
'visually-hidden-focusable',
// Screen reader
'sr-only',
// Container
'container',
'container-fluid',
// Row
'row',
// Display
'd-flex',
'd-none',
'd-block',
'd-inline-block',
'd-inline',
'd-grid',
// Common spacing
'mb-0', 'mb-1', 'mb-2', 'mb-3', 'mb-4', 'mb-5',
'mt-0', 'mt-1', 'mt-2', 'mt-3', 'mt-4', 'mt-5',
'me-0', 'me-1', 'me-2', 'me-3', 'me-4', 'me-5',
'ms-0', 'ms-1', 'ms-2', 'ms-3', 'ms-4', 'ms-5',
'mx-auto',
'py-0', 'py-1', 'py-2', 'py-3', 'py-4', 'py-5',
'px-0', 'px-1', 'px-2', 'px-3', 'px-4', 'px-5',
'p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5',
'gap-0', 'gap-1', 'gap-2', 'gap-3', 'gap-4', 'gap-5',
'g-0', 'g-1', 'g-2', 'g-3', 'g-4', 'g-5',
// Flex
'flex-wrap',
'flex-nowrap',
'flex-column',
'flex-row',
'justify-content-center',
'justify-content-between',
'justify-content-start',
'justify-content-end',
'align-items-center',
'align-items-start',
'align-items-end',
// Text
'text-center',
'text-start',
'text-end',
'text-white',
'text-muted',
'fw-bold',
'fw-normal',
'small',
// Images
'img-fluid',
// Border/rounded
'rounded',
'rounded-circle',
'border',
'border-0',
// Shadow
'shadow',
'shadow-sm',
'shadow-lg',
// Width
'w-100',
'w-auto',
'h-100',
'h-auto',
// Toast classes (plugin IP View Limit)
'toast-container',
'toast',
'toast-body',
'position-fixed',
'bottom-0',
'end-0',
'start-50',
'translate-middle-x',
'text-dark',
'bg-warning',
'btn-close',
'm-auto',
],
deep: [
// Grid responsive
/^col-/,
/^col$/,
// Display responsive
/^d-[a-z]+-/,
// Navbar responsive
/^navbar-expand/,
/^navbar-/,
// Responsive margins/padding
/^m[tbsexy]?-[a-z]+-/,
/^p[tbsexy]?-[a-z]+-/,
// Text responsive
/^text-[a-z]+-/,
// Flex responsive
/^flex-[a-z]+-/,
/^justify-content-[a-z]+-/,
/^align-items-[a-z]+-/,
],
greedy: [
// Form controls
/form-/,
/input-/,
// Buttons
/btn/,
// Cards
/card/,
// Navbar
/navbar/,
/nav-/,
// Tables
/table/,
// Alerts
/alert/,
// Toast
/toast/,
// Badges
/badge/,
// Lists
/list-/,
],
},
// Mantener variables CSS de Bootstrap
variables: true,
// Mantener keyframes
keyframes: true,
// Mantener font-face
fontFace: true,
});
if (purgeCSSResult.length === 0 || !purgeCSSResult[0].css) {
console.error('ERROR: PurgeCSS returned empty result');
process.exit(1);
}
// POST-PROCESAMIENTO: Remover propiedades que son manejadas por CSS crítico
// Esto evita que bootstrap-subset.min.css sobrescriba los estilos críticos
let processedCSS = purgeCSSResult[0].css;
// Remover position:relative de .navbar (manejado por CriticalCSSService con sticky)
// Regex: encuentra .navbar{...position:relative...} y remueve solo position:relative
processedCSS = processedCSS.replace(
/\.navbar\{([^}]*?)position:relative;?([^}]*)\}/g,
'.navbar{$1$2}'
);
// Limpiar posibles dobles punto y coma o punto y coma antes de }
processedCSS = processedCSS.replace(/;;+/g, ';');
processedCSS = processedCSS.replace(/;\}/g, '}');
console.log('\nPost-processing: Removed position:relative from .navbar (handled by CriticalCSSService)');
// Agregar header al CSS generado
const header = `/**
* Bootstrap 5.3.2 Subset - ROI Theme
*
* Generado automáticamente con PurgeCSS
* Contiene SOLO las clases Bootstrap usadas en el tema.
*
* Original: ${(inputSize / 1024).toFixed(2)} KB
* Subset: ${(processedCSS.length / 1024).toFixed(2)} KB
* Reduccion: ${(100 - (processedCSS.length / inputSize * 100)).toFixed(1)}%
*
* Generado: ${new Date().toISOString()}
*
* Para regenerar:
* node build-bootstrap-subset.js
*/
`;
const outputCSS = header + processedCSS;
// Escribir archivo
fs.writeFileSync(outputFile, outputCSS);
const outputSize = fs.statSync(outputFile).size;
const reduction = ((1 - outputSize / inputSize) * 100).toFixed(1);
console.log('');
console.log('SUCCESS!');
console.log('-'.repeat(60));
console.log(`Output: bootstrap-subset.min.css (${(outputSize / 1024).toFixed(2)} KB)`);
console.log(`Reduction: ${reduction}% smaller`);
console.log('-'.repeat(60));
console.log('');
console.log('Next steps:');
console.log('1. Update Inc/enqueue-scripts.php to use bootstrap-subset.min.css');
console.log('2. Test the theme thoroughly');
console.log('3. Run PageSpeed Insights to verify improvement');
} catch (error) {
console.error('ERROR:', error.message);
console.error(error.stack);
process.exit(1);
}
}
buildBootstrapSubset();

View File

@@ -2,7 +2,8 @@
/**
* The template for displaying the static front page
*
* Structure replicates template index.html lines 322-345 (Hero Section)
* Replica la estructura de single.php para consistencia visual.
* Grid layout: col-lg-9 (contenido) + col-lg-3 (sidebar)
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#front-page
*
@@ -13,118 +14,105 @@
get_header();
?>
<!-- Hero Title Section (Template líneas 322-345) -->
<div class="container-fluid py-5 mb-4 hero-title">
<div class="container">
<?php while (have_posts()) : the_post(); ?>
<main id="main-content" class="site-main" role="main">
<!-- Hero Section - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Main Content Grid -->
<div class="container">
<div class="row">
<!-- Main Content Column (col-lg-9) -->
<div class="col-lg-9">
<!-- Featured Image - Componente dinámico -->
<?php
while ( have_posts() ) :
the_post();
// Categories Section
$categories = get_the_category();
if ( ! empty( $categories ) && count( $categories ) > 0 ) :
?>
<div class="mb-3 d-flex justify-content-center">
<div class="d-flex gap-2 flex-wrap justify-content-center">
<?php
// Limit to 3 categories max
$cat_count = 0;
foreach ( $categories as $category ) :
if ( $cat_count >= 3 ) break;
if ( $category->slug === 'uncategorized' ) continue;
?>
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>" class="category-badge category-badge-hero">
<i class="bi bi-folder-fill me-1"></i>
<?php echo esc_html( $category->name ); ?>
</a>
<?php
$cat_count++;
endforeach;
?>
</div>
</div>
<?php endif; ?>
<!-- Page Title -->
<h1 class="display-5 fw-bold text-center">
<?php the_title(); ?>
</h1>
<?php endwhile; ?>
</div><!-- .container -->
</div><!-- .hero-title -->
<main id="main-content" class="site-main front-page" role="main">
<!-- Container Bootstrap (Template línea 347) -->
<div class="container">
<?php
while ( have_posts() ) :
the_post();
if (function_exists('roi_render_component')) {
echo roi_render_component('featured-image');
}
?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<!-- Front Page Content -->
<div class="entry-content">
<!-- Page Content -->
<article id="post-<?php the_ID(); ?>" <?php post_class('post-content'); ?>>
<?php
the_content();
// Display page links for paginated pages
wp_link_pages(
array(
'before' => '<div class="page-links">' . esc_html__( 'Pages:', 'roi-theme' ),
wp_link_pages(array(
'before' => '<div class="page-links">' . esc_html__('Pages:', 'roi-theme'),
'after' => '</div>',
)
);
));
?>
</div><!-- .entry-content -->
</article>
<!-- Front Page Footer -->
<?php if ( get_edit_post_link() ) : ?>
<footer class="entry-footer">
<!-- Share Buttons - Componente dinámico -->
<?php
// Edit post link for logged-in users with permission
edit_post_link(
sprintf(
wp_kses(
/* translators: %s: Page title. Only visible to screen readers. */
__( 'Edit<span class="screen-reader-text"> "%s"</span>', 'roi-theme' ),
array(
'span' => array(
'class' => array(),
),
)
),
get_the_title()
),
'<span class="edit-link">',
'</span>'
);
if (function_exists('roi_render_component')) {
echo roi_render_component('social-share');
}
?>
</footer><!-- .entry-footer -->
<?php endif; ?>
</article><!-- #post-<?php the_ID(); ?> -->
<!-- CTA Post - Componente dinámico -->
<?php
// Display comments section if enabled
if ( comments_open() || get_comments_number() ) :
comments_template();
endif;
endwhile; // End of the loop.
/**
* Hook to display additional content on front page
* This can be used to add featured posts, testimonials, etc.
*/
do_action( 'roi_front_page_content' );
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-post');
}
?>
</div><!-- .container -->
<!-- Related Posts - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('related-post');
}
?>
</main><!-- #main-content -->
<!-- Ad After Related Posts -->
<?php
if (function_exists('roi_render_ad_slot')) {
echo roi_render_ad_slot('after-related');
}
?>
</div><!-- .col-lg-9 -->
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<!-- CTA Box Sidebar - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
</div><!-- .row -->
</div><!-- .container -->
</main><!-- #main-content -->
<?php endwhile; ?>
<!-- Contact Form Section - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

View File

@@ -380,3 +380,127 @@ add_action('after_setup_theme', function() {
// desde la base de datos a través de sus respectivos Renderers.
// NO hardcodear CSS aquí - viola la arquitectura Clean Architecture.
// =============================================================================
// =============================================================================
// HELPER FUNCTION: roi_get_adsense_search_config()
// =============================================================================
/**
* Obtiene la configuracion de AdSense para resultados de busqueda
*
* Esta funcion es la API publica que el plugin roi-apu-search consume.
* El plugin NO debe acceder directamente a la tabla del tema.
*
* OPTIMIZACION: Una sola query carga todos los settings del componente.
*
* @return array Configuracion para JavaScript
*/
function roi_get_adsense_search_config(): array {
global $wpdb;
// =========================================================================
// CARGAR TODOS LOS SETTINGS EN UNA SOLA QUERY
// =========================================================================
$table = $wpdb->prefix . 'roi_theme_component_settings';
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT group_name, attribute_name, attribute_value
FROM {$table}
WHERE component_name = %s",
'adsense-placement'
));
if (empty($rows)) {
return ['enabled' => false];
}
// Organizar en array asociativo por grupo/atributo
$settings = [];
foreach ($rows as $row) {
if (!isset($settings[$row->group_name])) {
$settings[$row->group_name] = [];
}
// Decodificar valor
$value = $row->attribute_value;
if ($value === '1') $value = true;
elseif ($value === '0') $value = false;
else {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$value = $decoded;
}
}
$settings[$row->group_name][$row->attribute_name] = $value;
}
// Helper para obtener valor con default
$get = function(string $group, string $attr, $default = null) use ($settings) {
return $settings[$group][$attr] ?? $default;
};
// =========================================================================
// VALIDAR CONDICIONES GLOBALES
// =========================================================================
// AdSense global deshabilitado
if ($get('visibility', 'is_enabled', false) !== true) {
return ['enabled' => false];
}
// Ads en busqueda deshabilitados
if ($get('search_results', 'search_ads_enabled', false) !== true) {
return ['enabled' => false];
}
// Publisher ID vacio
$publisherId = $get('content', 'publisher_id', '');
if (empty($publisherId)) {
return ['enabled' => false];
}
// =========================================================================
// VALIDAR EXCLUSIONES (igual que el resto del sistema)
// =========================================================================
// Ocultar para usuarios logueados
if ($get('visibility', 'hide_for_logged_in', false) === true && is_user_logged_in()) {
return ['enabled' => false];
}
// Visibilidad por dispositivo
$isMobile = wp_is_mobile();
if ($isMobile && $get('visibility', 'show_on_mobile', true) !== true) {
return ['enabled' => false];
}
if (!$isMobile && $get('visibility', 'show_on_desktop', true) !== true) {
return ['enabled' => false];
}
// =========================================================================
// CONSTRUIR CONFIGURACION
// =========================================================================
return [
'enabled' => true,
'publisherId' => $publisherId,
'slots' => [
'auto' => $get('content', 'slot_auto', ''),
'inArticle' => $get('content', 'slot_inarticle', ''),
'autorelaxed' => $get('content', 'slot_autorelaxed', ''),
'display' => $get('content', 'slot_display', ''),
],
'topAd' => [
'enabled' => $get('search_results', 'search_top_ad_enabled', true) === true,
'format' => $get('search_results', 'search_top_ad_format', 'auto'),
],
'betweenAds' => [
'enabled' => $get('search_results', 'search_between_enabled', true) === true,
'max' => min(3, max(1, (int) $get('search_results', 'search_between_max', '1'))),
'format' => $get('search_results', 'search_between_format', 'in-article'),
'position' => $get('search_results', 'search_between_position', 'random'),
'every' => (int) $get('search_results', 'search_between_every', '5'),
],
'delay' => [
'enabled' => $get('forms', 'delay_enabled', true) === true,
'timeout' => (int) $get('forms', 'delay_timeout', '5000'),
],
];
}

60
minify-css.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
/**
* Simple CSS Minifier Script
* Run from command line: php minify-css.php
*/
function minify_css($css) {
// Remove comments
$css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css);
// Remove space after colons
$css = str_replace(': ', ':', $css);
// Remove whitespace
$css = str_replace(array("\r\n", "\r", "\n", "\t", ' ', ' ', ' '), '', $css);
// Remove space before and after specific characters
$css = preg_replace('/\s*([{};,>+~])\s*/', '$1', $css);
// Remove last semicolon before closing brace
$css = str_replace(';}', '}', $css);
// Trim
$css = trim($css);
return $css;
}
$files = [
'Assets/Css/css-global-accessibility.css' => 'Assets/Css/css-global-accessibility.min.css',
'Assets/Css/style.css' => 'Assets/Css/style.min.css',
];
$base_path = __DIR__ . '/';
foreach ($files as $source => $dest) {
$source_path = $base_path . $source;
$dest_path = $base_path . $dest;
if (file_exists($source_path)) {
$css = file_get_contents($source_path);
$minified = minify_css($css);
file_put_contents($dest_path, $minified);
$original_size = strlen($css);
$minified_size = strlen($minified);
$savings = $original_size - $minified_size;
$percent = round(($savings / $original_size) * 100, 1);
echo "Minified: $source\n";
echo " Original: " . number_format($original_size) . " bytes\n";
echo " Minified: " . number_format($minified_size) . " bytes\n";
echo " Savings: " . number_format($savings) . " bytes ($percent%)\n\n";
} else {
echo "File not found: $source\n";
}
}
echo "Done!\n";

164
page.php
View File

@@ -2,9 +2,8 @@
/**
* The template for displaying all pages
*
* This is the template that displays all pages by default.
* Please note that this is the WordPress construct of pages and that
* other 'pages' on your WordPress site will use a different template.
* Replica la estructura de single.php para consistencia visual.
* Grid layout: col-lg-9 (contenido) + col-lg-3 (sidebar)
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#single-page
*
@@ -15,112 +14,105 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<?php while (have_posts()) : the_post(); ?>
<div class="content-wrapper">
<main id="main-content" class="site-main" role="main">
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Hero Section - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Main Content Grid -->
<div class="container">
<div class="row">
<!-- Main Content Column (col-lg-9) -->
<div class="col-lg-9">
<!-- Featured Image - Componente dinámico -->
<?php
while ( have_posts() ) :
the_post();
if (function_exists('roi_render_component')) {
echo roi_render_component('featured-image');
}
?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<!-- Featured Image -->
<?php if ( has_post_thumbnail() ) : ?>
<div class="post-thumbnail">
<?php
the_post_thumbnail(
'roi-featured-large',
array(
'alt' => the_title_attribute(
array(
'echo' => false,
)
),
'loading' => 'eager',
)
);
?>
</div>
<?php endif; ?>
<!-- Page Header -->
<header class="entry-header">
<h1 class="entry-title">
<?php the_title(); ?>
</h1>
</header><!-- .entry-header -->
<!-- Page Content -->
<div class="entry-content">
<article id="post-<?php the_ID(); ?>" <?php post_class('post-content'); ?>>
<?php
the_content();
// Display page links for paginated pages
wp_link_pages(
array(
'before' => '<div class="page-links">' . esc_html__( 'Pages:', 'roi-theme' ),
wp_link_pages(array(
'before' => '<div class="page-links">' . esc_html__('Pages:', 'roi-theme'),
'after' => '</div>',
)
);
));
?>
</div><!-- .entry-content -->
</article>
<!-- Page Footer -->
<?php if ( get_edit_post_link() ) : ?>
<footer class="entry-footer">
<!-- Share Buttons - Componente dinámico -->
<?php
// Edit post link for logged-in users with permission
edit_post_link(
sprintf(
wp_kses(
/* translators: %s: Page title. Only visible to screen readers. */
__( 'Edit<span class="screen-reader-text"> "%s"</span>', 'roi-theme' ),
array(
'span' => array(
'class' => array(),
),
)
),
get_the_title()
),
'<span class="edit-link">',
'</span>'
);
if (function_exists('roi_render_component')) {
echo roi_render_component('social-share');
}
?>
</footer><!-- .entry-footer -->
<?php endif; ?>
</article><!-- #post-<?php the_ID(); ?> -->
<!-- CTA Post - Componente dinámico -->
<?php
// Display comments section if enabled
if ( comments_open() || get_comments_number() ) :
comments_template();
endif;
endwhile; // End of the loop.
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-post');
}
?>
</div><!-- #primary -->
<!-- Related Posts - Componente dinámico -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
if (function_exists('roi_render_component')) {
echo roi_render_component('related-post');
}
?>
</div><!-- .content-wrapper -->
<!-- Ad After Related Posts -->
<?php
if (function_exists('roi_render_ad_slot')) {
echo roi_render_ad_slot('after-related');
}
?>
</main><!-- #main-content -->
</div><!-- .col-lg-9 -->
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<!-- CTA Box Sidebar - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
</div><!-- .row -->
</div><!-- .container -->
</main><!-- #main-content -->
<?php endwhile; ?>
<!-- Contact Form Section - Componente dinámico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();