14 Commits

Author SHA1 Message Date
FrankZamora
ff5ba25505 feat(php): implement cache-first architecture hook
Add CacheFirstHooksRegistrar that fires roi_theme_before_page_serve
hook on template_redirect priority 0 for singular pages.

- Only fires for anonymous users (cache doesn't apply to logged in)
- Only fires for singular pages (posts, pages, CPTs)
- Provides post_id to external plugins
- Does NOT define DONOTCACHEPAGE (allows page caching)

Plan 1000.01 implementation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:11:48 -06:00
FrankZamora
eab974d14c docs(config): add cache-first-architecture specification
Define arquitectura cache-first para ROI-Theme:
- Hook roi_theme_before_page_serve en template_redirect p=0
- Solo para páginas singulares y usuarios anónimos
- Permite que plugins externos evalúen acceso antes de servir página
- NO define DONOTCACHEPAGE (permite cache)

Plan 1000.01 - Preparación para integración con IP View Limit.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:07:14 -06:00
FrankZamora
b509b1a2b4 fix(php): toc fallback to raw content when filtered has no headings
When plugins like Thrive Visual Editor transform content for
non-logged users, headings may be removed from the filtered content.
This fix uses raw post_content as fallback when filtered content
has no headings but raw content does.

Also removes temporary debug logging added for diagnosis.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:08:24 -06:00
FrankZamora
83d113d669 chore(php): add more debug for toc heading detection
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:05:51 -06:00
FrankZamora
0c1908e7d1 chore(php): add toc debug logging for guest visibility issue
Temporary debug logging to diagnose why TOC shows for logged users
but not for guests. Logs visibility checks at each layer.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:03:42 -06:00
FrankZamora
5333531be4 fix(templates): add missing components to archive templates
- Add social-share component to category.php and archive.php
- Add related-post component to category.php and archive.php
- Now archive templates have same components as single.php

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:40:53 -06:00
FrankZamora
fb68f2023c fix(theme): improve post-grid spacing, pagination and archive templates
- Fix flexbox gap issue causing unequal horizontal/vertical spacing
- Reset Bootstrap row/col margins to use only CSS gap property
- Replace WordPress pagination with Bootstrap-style pagination
- Add cta-post component to category.php and archive.php templates
- Fix spacing controls UI with separate horizontal/vertical gap fields
- Update FieldMapper with new gap_horizontal and gap_vertical attributes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:26:19 -06:00
FrankZamora
79e91f59ee feat(theme): add [roi_post_grid] shortcode for static pages
- Create PostGridShortcodeRegistrar for WordPress shortcode registration
- Implement RenderPostGridUseCase following Clean Architecture
- Add PostGridQueryBuilder for custom WP_Query construction
- Add PostGridShortcodeRenderer for HTML/CSS generation
- Register shortcode in DIContainer with proper DI
- Add shortcode usage guide in post-grid admin panel
- Fix sidebar layout: add hide_for_logged_in check to wrapper visibility

Shortcode attributes: category, tag, author, posts_per_page, columns,
show_pagination, show_thumbnail, show_excerpt, show_meta, etc.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 21:33:20 -06:00
FrankZamora
c23dc22d76 feat(templates): add archive-header and post-grid components
- Add ArchiveHeader component (schema, renderer, formbuilder)
- Add PostGrid component (schema, renderer, formbuilder)
- Unify archive templates (home, archive, category, tag,
  author, date, search)
- Add page visibility system with VisibilityDefaults
- Register components in AdminDashboardRenderer
- Fix boolean conversion in functions-addon.php
- All 172 unit tests passed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 20:36:27 -06:00
FrankZamora
b79569c5e7 docs: add templates-unificados openspec specification
Defines unified listing templates architecture.

Key additions:
- Two new components: archive-header, post-grid
- 10-phase implementation sequence
- _page_visibility group pattern
- Graceful handling of missing content
- CSS pagination via CSSGeneratorService

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 19:14:09 -06:00
FrankZamora
6be292e085 chore: eliminar related-posts.php legacy (plan 101 fase 3)
- eliminar inc/related-posts.php (reemplazado por relatedpostrenderer)
- eliminar require en functions.php

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 18:17:48 -06:00
FrankZamora
885276aad1 chore: purgar archivos legacy (plan 101 fase 2)
- eliminar 5 template parts reemplazados por renderers
- eliminar header.js (90% codigo muerto)
- eliminar apu-tables-auto-class.js (migrdo a php)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 18:11:04 -06:00
FrankZamora
1e6a076904 chore: purgar archivos no utilizados (plan 101 fase 1)
- eliminar carpetas vacias admin/herosection y bootstrapicons
- eliminar 7 scripts legacy con credenciales hardcodeadas
- eliminar formbuilder duplicado en shared/infrastructure/ui
- eliminar 11 archivos .gitkeep en carpetas con contenido

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 18:09:52 -06:00
FrankZamora
a33c43a104 fix(admin): corregir guardado customcssmanager con toast
- crear bootstrap para handler post en admin_init
- ocultar botones globales para custom-css-manager
- simplificar formbuilder eliminando handler duplicado
- reemplazar alert por toast para notificaciones

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 15:02:06 -06:00
72 changed files with 5819 additions and 4336 deletions

View File

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ArchiveHeader\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Archive Header
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class ArchiveHeaderFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'archive-header';
}
public function getFieldMapping(): array
{
return [
// Visibility
'archiveHeaderEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'archiveHeaderShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'archiveHeaderShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Page Visibility (grupo especial _page_visibility)
'archiveHeaderVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'archiveHeaderVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'archiveHeaderVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'archiveHeaderVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'archiveHeaderVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions)
'archiveHeaderExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'archiveHeaderExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'archiveHeaderExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'archiveHeaderExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'archiveHeaderBlogTitle' => ['group' => 'content', 'attribute' => 'blog_title'],
'archiveHeaderShowPostCount' => ['group' => 'content', 'attribute' => 'show_post_count'],
'archiveHeaderShowDescription' => ['group' => 'content', 'attribute' => 'show_description'],
'archiveHeaderCategoryPrefix' => ['group' => 'content', 'attribute' => 'category_prefix'],
'archiveHeaderTagPrefix' => ['group' => 'content', 'attribute' => 'tag_prefix'],
'archiveHeaderAuthorPrefix' => ['group' => 'content', 'attribute' => 'author_prefix'],
'archiveHeaderDatePrefix' => ['group' => 'content', 'attribute' => 'date_prefix'],
'archiveHeaderSearchPrefix' => ['group' => 'content', 'attribute' => 'search_prefix'],
'archiveHeaderCountSingular' => ['group' => 'content', 'attribute' => 'posts_count_singular'],
'archiveHeaderCountPlural' => ['group' => 'content', 'attribute' => 'posts_count_plural'],
// Typography
'archiveHeaderHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
'archiveHeaderTitleSize' => ['group' => 'typography', 'attribute' => 'title_size'],
'archiveHeaderTitleWeight' => ['group' => 'typography', 'attribute' => 'title_weight'],
'archiveHeaderDescriptionSize' => ['group' => 'typography', 'attribute' => 'description_size'],
'archiveHeaderCountSize' => ['group' => 'typography', 'attribute' => 'count_size'],
// Colors
'archiveHeaderTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'archiveHeaderPrefixColor' => ['group' => 'colors', 'attribute' => 'prefix_color'],
'archiveHeaderDescriptionColor' => ['group' => 'colors', 'attribute' => 'description_color'],
'archiveHeaderCountBgColor' => ['group' => 'colors', 'attribute' => 'count_bg_color'],
'archiveHeaderCountTextColor' => ['group' => 'colors', 'attribute' => 'count_text_color'],
// Spacing
'archiveHeaderMarginTop' => ['group' => 'spacing', 'attribute' => 'margin_top'],
'archiveHeaderMarginBottom' => ['group' => 'spacing', 'attribute' => 'margin_bottom'],
'archiveHeaderPadding' => ['group' => 'spacing', 'attribute' => 'padding'],
'archiveHeaderTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'archiveHeaderCountPadding' => ['group' => 'spacing', 'attribute' => 'count_padding'],
// Behavior
'archiveHeaderIsSticky' => ['group' => 'behavior', 'attribute' => 'is_sticky'],
'archiveHeaderStickyOffset' => ['group' => 'behavior', 'attribute' => 'sticky_offset'],
];
}
}

View File

@@ -0,0 +1,492 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ArchiveHeader\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Archive Header
*
* @package ROITheme\Admin\ArchiveHeader\Infrastructure\Ui
*/
final class ArchiveHeaderFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildBehaviorGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-layout-text-window me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Cabecera de Archivo';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Cabecera dinamica para paginas de listados (blog, categorias, tags, autor, fecha, busqueda)';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="archive-header">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('archiveHeaderEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('archiveHeaderShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('archiveHeaderShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// Page visibility checkboxes
$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', false);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
$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('archiveHeaderVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// Exclusions
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'archiveHeader');
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// Blog Title
$blogTitle = $this->renderer->getFieldValue($componentId, 'content', 'blog_title', 'Blog');
$html .= ' <div class="mb-3">';
$html .= ' <label for="archiveHeaderBlogTitle" class="form-label small mb-1 fw-semibold">Titulo del blog</label>';
$html .= ' <input type="text" id="archiveHeaderBlogTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($blogTitle) . '">';
$html .= ' <small class="text-muted">Mostrado en la pagina principal del blog</small>';
$html .= ' </div>';
// Switches
$showPostCount = $this->renderer->getFieldValue($componentId, 'content', 'show_post_count', true);
$html .= $this->buildSwitch('archiveHeaderShowPostCount', 'Mostrar contador de posts', 'bi-hash', $showPostCount);
$showDescription = $this->renderer->getFieldValue($componentId, 'content', 'show_description', true);
$html .= $this->buildSwitch('archiveHeaderShowDescription', 'Mostrar descripcion', 'bi-text-paragraph', $showDescription);
// Prefixes section
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-tag me-1" style="color: #FF8600;"></i>';
$html .= ' Prefijos de titulo';
$html .= ' </p>';
$html .= ' <div class="row g-2">';
$categoryPrefix = $this->renderer->getFieldValue($componentId, 'content', 'category_prefix', 'Categoria:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCategoryPrefix" class="form-label small mb-1">Categoria</label>';
$html .= ' <input type="text" id="archiveHeaderCategoryPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($categoryPrefix) . '">';
$html .= ' </div>';
$tagPrefix = $this->renderer->getFieldValue($componentId, 'content', 'tag_prefix', 'Etiqueta:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTagPrefix" class="form-label small mb-1">Etiqueta</label>';
$html .= ' <input type="text" id="archiveHeaderTagPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($tagPrefix) . '">';
$html .= ' </div>';
$authorPrefix = $this->renderer->getFieldValue($componentId, 'content', 'author_prefix', 'Articulos de:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderAuthorPrefix" class="form-label small mb-1">Autor</label>';
$html .= ' <input type="text" id="archiveHeaderAuthorPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($authorPrefix) . '">';
$html .= ' </div>';
$datePrefix = $this->renderer->getFieldValue($componentId, 'content', 'date_prefix', 'Archivo:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderDatePrefix" class="form-label small mb-1">Fecha</label>';
$html .= ' <input type="text" id="archiveHeaderDatePrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($datePrefix) . '">';
$html .= ' </div>';
$searchPrefix = $this->renderer->getFieldValue($componentId, 'content', 'search_prefix', 'Resultados para:');
$html .= ' <div class="col-12">';
$html .= ' <label for="archiveHeaderSearchPrefix" class="form-label small mb-1">Busqueda</label>';
$html .= ' <input type="text" id="archiveHeaderSearchPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($searchPrefix) . '">';
$html .= ' </div>';
$html .= ' </div>';
// Post count texts
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-123 me-1" style="color: #FF8600;"></i>';
$html .= ' Textos del contador';
$html .= ' </p>';
$html .= ' <div class="row g-2">';
$countSingular = $this->renderer->getFieldValue($componentId, 'content', 'posts_count_singular', 'publicacion');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCountSingular" class="form-label small mb-1">Singular</label>';
$html .= ' <input type="text" id="archiveHeaderCountSingular" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countSingular) . '">';
$html .= ' </div>';
$countPlural = $this->renderer->getFieldValue($componentId, 'content', 'posts_count_plural', 'publicaciones');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCountPlural" class="form-label small mb-1">Plural</label>';
$html .= ' <input type="text" id="archiveHeaderCountPlural" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countPlural) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildBehaviorGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-gear me-2" style="color: #FF8600;"></i>';
$html .= ' Comportamiento';
$html .= ' </h5>';
$isSticky = $this->renderer->getFieldValue($componentId, 'behavior', 'is_sticky', false);
$html .= $this->buildSwitch('archiveHeaderIsSticky', 'Header fijo al hacer scroll', 'bi-pin-angle', $isSticky);
$stickyOffset = $this->renderer->getFieldValue($componentId, 'behavior', 'sticky_offset', '0');
$html .= ' <div class="mb-0">';
$html .= ' <label for="archiveHeaderStickyOffset" class="form-label small mb-1 fw-semibold">Offset sticky</label>';
$html .= ' <input type="text" id="archiveHeaderStickyOffset" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($stickyOffset) . '">';
$html .= ' <small class="text-muted">Distancia desde el top cuando es sticky (ej: 60px)</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
// Heading level
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h1');
$html .= ' <div class="mb-3">';
$html .= ' <label for="archiveHeaderHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
$html .= ' <select id="archiveHeaderHeadingLevel" class="form-select form-select-sm">';
foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $level) {
$selected = $headingLevel === $level ? ' selected' : '';
$html .= sprintf(' <option value="%s"%s>%s</option>', $level, $selected, strtoupper($level));
}
$html .= ' </select>';
$html .= ' <small class="text-muted">Importante para SEO y accesibilidad</small>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$titleSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_size', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="archiveHeaderTitleSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleSize) . '">';
$html .= ' </div>';
$titleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_weight', '700');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="archiveHeaderTitleWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$descriptionSize = $this->renderer->getFieldValue($componentId, 'typography', 'description_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderDescriptionSize" class="form-label small mb-1 fw-semibold">Tamano descripcion</label>';
$html .= ' <input type="text" id="archiveHeaderDescriptionSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($descriptionSize) . '">';
$html .= ' </div>';
$countSize = $this->renderer->getFieldValue($componentId, 'typography', 'count_size', '0.875rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCountSize" class="form-label small mb-1 fw-semibold">Tamano contador</label>';
$html .= ' <input type="text" id="archiveHeaderCountSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countSize) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#0E2337');
$html .= $this->buildColorPicker('archiveHeaderTitleColor', 'Titulo', $titleColor);
$prefixColor = $this->renderer->getFieldValue($componentId, 'colors', 'prefix_color', '#6b7280');
$html .= $this->buildColorPicker('archiveHeaderPrefixColor', 'Prefijo', $prefixColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$descriptionColor = $this->renderer->getFieldValue($componentId, 'colors', 'description_color', '#6b7280');
$html .= $this->buildColorPicker('archiveHeaderDescriptionColor', 'Descripcion', $descriptionColor);
$html .= ' </div>';
$html .= ' <p class="small fw-semibold mb-2">Contador de posts</p>';
$html .= ' <div class="row g-2 mb-0">';
$countBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'count_bg_color', '#FF8600');
$html .= $this->buildColorPicker('archiveHeaderCountBgColor', 'Fondo', $countBgColor);
$countTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'count_text_color', '#ffffff');
$html .= $this->buildColorPicker('archiveHeaderCountTextColor', 'Texto', $countTextColor);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$marginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_top', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="archiveHeaderMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginTop) . '">';
$html .= ' </div>';
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_bottom', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="archiveHeaderMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$padding = $this->renderer->getFieldValue($componentId, 'spacing', 'padding', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderPadding" class="form-label small mb-1 fw-semibold">Padding</label>';
$html .= ' <input type="text" id="archiveHeaderPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($padding) . '">';
$html .= ' </div>';
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="archiveHeaderTitleMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="mb-0">';
$countPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'count_padding', '0.25rem 0.75rem');
$html .= ' <label for="archiveHeaderCountPadding" class="form-label small mb-1 fw-semibold">Padding contador</label>';
$html .= ' <input type="text" id="archiveHeaderCountPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countPadding) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-2">';
$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 .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
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

@@ -109,7 +109,7 @@ final class CtaBoxSidebarFormBuilder
$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);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
// Grid 3 columnas según Design System

View File

@@ -8,9 +8,10 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para ID único de snippet CSS
*
* Soporta dos formatos:
* Soporta tres formatos:
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
* 2. Legacy/Migración: kebab-case (ej: "cls-tables-apu", "generic-tables")
* 2. Legacy con prefijo: css_[descriptive]_[number] (ej: "css_tablas_apu_1764624826")
* 3. Legacy kebab-case: (ej: "cls-tables-apu", "generic-tables")
*
* Esto permite migrar snippets existentes sin romper IDs.
*/
@@ -18,6 +19,7 @@ final class SnippetId
{
private const PREFIX = 'css_';
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/';
private const PATTERN_LEGACY_PREFIX = '/^css_[a-z0-9_]+$/';
private const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
private function __construct(
@@ -47,7 +49,8 @@ final class SnippetId
// Validar formato generado (css_*)
if (str_starts_with($id, self::PREFIX)) {
if (!preg_match(self::PATTERN_GENERATED, $id)) {
// Acepta formato nuevo (css_timestamp_random) o legacy (css_descriptivo_numero)
if (!preg_match(self::PATTERN_GENERATED, $id) && !preg_match(self::PATTERN_LEGACY_PREFIX, $id)) {
throw new ValidationException(
sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id)
);

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Bootstrap;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Bootstrap para CustomCSSManager
*
* Registra el handler de formulario POST en admin_init
* ANTES de que se envíen headers HTTP
*/
final class CustomCSSManagerBootstrap
{
private const NONCE_ACTION = 'roi_custom_css_manager';
public static function init(): void
{
add_action('admin_init', [self::class, 'handleFormSubmission']);
}
public static function handleFormSubmission(): void
{
if (!isset($_POST['roi_css_action'])) {
return;
}
// Verificar que estamos en la página correcta
$page = $_GET['page'] ?? '';
$component = $_GET['component'] ?? '';
if ($page !== 'roi-theme-admin' || $component !== 'custom-css-manager') {
return;
}
// Verificar nonce
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
wp_die('Nonce verification failed');
}
// Verificar permisos
if (!current_user_can('manage_options')) {
wp_die('Insufficient permissions');
}
global $wpdb;
$repository = new WordPressSnippetRepository($wpdb);
$saveUseCase = new SaveSnippetUseCase($repository);
$deleteUseCase = new DeleteSnippetUseCase($repository);
$action = sanitize_text_field($_POST['roi_css_action']);
try {
match ($action) {
'save' => self::processSave($_POST, $saveUseCase),
'delete' => self::processDelete($_POST, $deleteUseCase),
default => null,
};
// Redirect con mensaje de éxito
$redirect_url = admin_url('admin.php?page=roi-theme-admin&component=custom-css-manager&roi_message=success');
wp_redirect($redirect_url);
exit;
} catch (ValidationException $e) {
$redirect_url = admin_url('admin.php?page=roi-theme-admin&component=custom-css-manager&roi_message=error&roi_error=' . urlencode($e->getMessage()));
wp_redirect($redirect_url);
exit;
}
}
private static function processSave(array $data, SaveSnippetUseCase $useCase): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
if (empty($id)) {
$id = SnippetId::generate()->value();
}
$request = SaveSnippetRequest::fromArray([
'id' => $id,
'name' => sanitize_text_field($data['snippet_name'] ?? ''),
'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
'enabled' => isset($data['snippet_enabled']),
'order' => absint($data['snippet_order'] ?? 100),
]);
$useCase->execute($request);
}
private static function processDelete(array $data, DeleteSnippetUseCase $useCase): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
if (empty($id)) {
throw new ValidationException('ID de snippet requerido para eliminar');
}
$useCase->execute($id);
}
}

View File

@@ -5,12 +5,7 @@ namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* FormBuilder para gestión de CSS snippets en Admin Panel
@@ -19,6 +14,9 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
* - Constructor recibe AdminDashboardRenderer
* - Método buildForm() genera el HTML del formulario
*
* NOTA: El handler de formulario POST está en CustomCSSManagerBootstrap
* para que se ejecute en admin_init ANTES de que se envíen headers HTTP.
*
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
*/
final class CustomCSSManagerFormBuilder
@@ -28,120 +26,15 @@ final class CustomCSSManagerFormBuilder
private WordPressSnippetRepository $repository;
private GetAllSnippetsUseCase $getAllUseCase;
private SaveSnippetUseCase $saveUseCase;
private DeleteSnippetUseCase $deleteUseCase;
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {
// Crear repositorio y Use Cases internamente
// Crear repositorio y Use Case para listar snippets
global $wpdb;
$this->repository = new WordPressSnippetRepository($wpdb);
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
$this->saveUseCase = new SaveSnippetUseCase($this->repository);
$this->deleteUseCase = new DeleteSnippetUseCase($this->repository);
// Registrar handler de formulario POST
$this->registerFormHandler();
}
/**
* Registra handler para procesar formularios POST
*/
private function registerFormHandler(): void
{
// Solo registrar una vez
static $registered = false;
if ($registered) {
return;
}
$registered = true;
add_action('admin_init', function() {
$this->handleFormSubmission();
});
}
/**
* Procesa envío de formulario
*/
public function handleFormSubmission(): void
{
if (!isset($_POST['roi_css_action'])) {
return;
}
// Verificar nonce
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
wp_die('Nonce verification failed');
}
// Verificar permisos
if (!current_user_can('manage_options')) {
wp_die('Insufficient permissions');
}
$action = sanitize_text_field($_POST['roi_css_action']);
try {
match ($action) {
'save' => $this->processSave($_POST),
'delete' => $this->processDelete($_POST),
default => null,
};
// Redirect con mensaje de éxito
wp_redirect(add_query_arg('roi_message', 'success', wp_get_referer()));
exit;
} catch (ValidationException $e) {
// Redirect con mensaje de error
wp_redirect(add_query_arg([
'roi_message' => 'error',
'roi_error' => urlencode($e->getMessage())
], wp_get_referer()));
exit;
}
}
/**
* Procesa guardado de snippet
*/
private function processSave(array $data): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
// Generar ID si es nuevo
if (empty($id)) {
$id = SnippetId::generate()->value();
}
$request = SaveSnippetRequest::fromArray([
'id' => $id,
'name' => sanitize_text_field($data['snippet_name'] ?? ''),
'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
'enabled' => isset($data['snippet_enabled']),
'order' => absint($data['snippet_order'] ?? 100),
]);
$this->saveUseCase->execute($request);
}
/**
* Procesa eliminación de snippet
*/
private function processDelete(array $data): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
if (empty($id)) {
throw new ValidationException('ID de snippet requerido para eliminar');
}
$this->deleteUseCase->execute($id);
// NOTA: El handler POST está en CustomCSSManagerBootstrap (admin_init)
}
/**
@@ -160,13 +53,9 @@ final class CustomCSSManagerFormBuilder
// Header
$html .= $this->buildHeader($componentId, count($snippets));
// Mensajes flash
// Toast para mensajes (usa el sistema existente de admin-dashboard.js)
if ($message) {
$html .= sprintf(
'<div class="alert alert-%s m-3">%s</div>',
esc_attr($message['type']),
esc_html($message['text'])
);
$html .= $this->buildToastTrigger($message);
}
// Lista de snippets existentes
@@ -367,7 +256,7 @@ final class CustomCSSManagerFormBuilder
// Botones
$html .= ' <div class="col-12">';
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">';
$html .= ' <i class="bi bi-save me-1"></i> Guardar Snippet';
$html .= ' <i class="bi bi-check-circle me-1"></i> Guardar Cambios';
$html .= ' </button>';
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
@@ -449,14 +338,84 @@ final class CustomCSSManagerFormBuilder
$message = $_GET['roi_message'] ?? null;
if ($message === 'success') {
return ['type' => 'success', 'text' => 'Snippet guardado correctamente'];
return ['type' => 'success', 'text' => 'Cambios guardados correctamente'];
}
if ($message === 'error') {
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
return ['type' => 'danger', 'text' => $error];
return ['type' => 'error', 'text' => $error];
}
return null;
}
/**
* Genera script para mostrar Toast
*/
private function buildToastTrigger(array $message): string
{
$type = esc_js($message['type']);
$text = esc_js($message['text']);
// Mapear tipo a configuración de Bootstrap
$typeMap = [
'success' => ['bg' => 'success', 'icon' => 'bi-check-circle-fill'],
'error' => ['bg' => 'danger', 'icon' => 'bi-x-circle-fill'],
];
$config = $typeMap[$type] ?? $typeMap['success'];
$bg = $config['bg'];
$icon = $config['icon'];
return <<<HTML
<script>
document.addEventListener('DOMContentLoaded', function() {
// Crear container de toasts si no existe
let toastContainer = document.getElementById('roiToastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'roiToastContainer';
toastContainer.className = 'toast-container position-fixed start-50 translate-middle-x';
toastContainer.style.top = '60px';
toastContainer.style.zIndex = '999999';
document.body.appendChild(toastContainer);
}
// Crear toast
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="\${toastId}" class="toast align-items-center text-white bg-{$bg} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi {$icon} me-2"></i>
<strong>{$text}</strong>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
// Mostrar toast
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Eliminar del DOM después de ocultarse
toastElement.addEventListener('hidden.bs.toast', function() {
toastElement.remove();
});
// Limpiar parámetros de URL sin recargar
const url = new URL(window.location.href);
url.searchParams.delete('roi_message');
url.searchParams.delete('roi_error');
window.history.replaceState({}, '', url.toString());
});
</script>
HTML;
}
}

View File

@@ -98,6 +98,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'Related Posts',
'icon' => 'bi-grid-3x3-gap',
],
'archive-header' => [
'id' => 'archive-header',
'label' => 'Archive Header',
'icon' => 'bi-layout-text-window',
],
'post-grid' => [
'id' => 'post-grid',
'label' => 'Post Grid',
'icon' => 'bi-grid-3x3',
],
'contact-form' => [
'id' => 'contact-form',
'label' => 'Contact Form',

View File

@@ -37,7 +37,7 @@ final class ComponentGroupRegistry
'label' => __('Contenido Principal', 'roi-theme'),
'icon' => 'bi-file-richtext',
'description' => __('Secciones principales de páginas y posts', 'roi-theme'),
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post']
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post', 'archive-header', 'post-grid']
],
'ctas-conversion' => [
'label' => __('CTAs & Conversión', 'roi-theme'),

View File

@@ -60,6 +60,12 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
</div>
</div>
<?php
// Componentes con sistema de guardado propio (CRUD de entidades)
$componentsWithOwnSaveSystem = ['custom-css-manager'];
if (!in_array($activeComponent, $componentsWithOwnSaveSystem, true)):
?>
<!-- Botones Globales Save/Cancel -->
<div class="d-flex justify-content-end gap-2 p-3 rounded border mt-4" style="background-color: #f8f9fa; border-color: #e9ecef !important;">
<button type="button" class="btn btn-outline-secondary" id="cancelChanges">
@@ -71,4 +77,5 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
<?php echo esc_html__('Guardar Cambios', 'roi-theme'); ?>
</button>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\PostGrid\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Post Grid
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class PostGridFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'post-grid';
}
public function getFieldMapping(): array
{
return [
// Visibility
'postGridEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'postGridShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'postGridShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Page Visibility (grupo especial _page_visibility)
'postGridVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'postGridVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'postGridVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'postGridVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'postGridVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions)
'postGridExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'postGridExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'postGridExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'postGridExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'postGridShowThumbnail' => ['group' => 'content', 'attribute' => 'show_thumbnail'],
'postGridShowExcerpt' => ['group' => 'content', 'attribute' => 'show_excerpt'],
'postGridShowMeta' => ['group' => 'content', 'attribute' => 'show_meta'],
'postGridShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'postGridExcerptLength' => ['group' => 'content', 'attribute' => 'excerpt_length'],
'postGridReadMoreText' => ['group' => 'content', 'attribute' => 'read_more_text'],
'postGridNoPostsMessage' => ['group' => 'content', 'attribute' => 'no_posts_message'],
// Layout
'postGridColumnsDesktop' => ['group' => 'layout', 'attribute' => 'columns_desktop'],
'postGridColumnsTablet' => ['group' => 'layout', 'attribute' => 'columns_tablet'],
'postGridColumnsMobile' => ['group' => 'layout', 'attribute' => 'columns_mobile'],
'postGridImagePosition' => ['group' => 'layout', 'attribute' => 'image_position'],
// Media
'postGridFallbackImage' => ['group' => 'media', 'attribute' => 'fallback_image'],
'postGridFallbackImageAlt' => ['group' => 'media', 'attribute' => 'fallback_image_alt'],
// Typography
'postGridHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
'postGridCardTitleSize' => ['group' => 'typography', 'attribute' => 'card_title_size'],
'postGridCardTitleWeight' => ['group' => 'typography', 'attribute' => 'card_title_weight'],
'postGridExcerptSize' => ['group' => 'typography', 'attribute' => 'excerpt_size'],
'postGridMetaSize' => ['group' => 'typography', 'attribute' => 'meta_size'],
// Colors
'postGridCardBgColor' => ['group' => 'colors', 'attribute' => 'card_bg_color'],
'postGridCardTitleColor' => ['group' => 'colors', 'attribute' => 'card_title_color'],
'postGridCardHoverBgColor' => ['group' => 'colors', 'attribute' => 'card_hover_bg_color'],
'postGridCardBorderColor' => ['group' => 'colors', 'attribute' => 'card_border_color'],
'postGridCardHoverBorderColor' => ['group' => 'colors', 'attribute' => 'card_hover_border_color'],
'postGridExcerptColor' => ['group' => 'colors', 'attribute' => 'excerpt_color'],
'postGridMetaColor' => ['group' => 'colors', 'attribute' => 'meta_color'],
'postGridCategoryBgColor' => ['group' => 'colors', 'attribute' => 'category_bg_color'],
'postGridCategoryTextColor' => ['group' => 'colors', 'attribute' => 'category_text_color'],
'postGridPaginationColor' => ['group' => 'colors', 'attribute' => 'pagination_color'],
'postGridPaginationActiveBg' => ['group' => 'colors', 'attribute' => 'pagination_active_bg'],
'postGridPaginationActiveColor' => ['group' => 'colors', 'attribute' => 'pagination_active_color'],
// Spacing
'postGridGapHorizontal' => ['group' => 'spacing', 'attribute' => 'gap_horizontal'],
'postGridGapVertical' => ['group' => 'spacing', 'attribute' => 'gap_vertical'],
'postGridCardPadding' => ['group' => 'spacing', 'attribute' => 'card_padding'],
'postGridSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
'postGridSectionMarginBottom' => ['group' => 'spacing', 'attribute' => 'section_margin_bottom'],
// Visual Effects
'postGridCardBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'card_border_radius'],
'postGridCardShadow' => ['group' => 'visual_effects', 'attribute' => 'card_shadow'],
'postGridCardHoverShadow' => ['group' => 'visual_effects', 'attribute' => 'card_hover_shadow'],
'postGridCardTransition' => ['group' => 'visual_effects', 'attribute' => 'card_transition'],
'postGridImageBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'image_border_radius'],
];
}
}

View File

@@ -0,0 +1,781 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\PostGrid\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* PostGridFormBuilder - Genera formulario admin para Post Grid
*
* Sigue el mismo patron visual que RelatedPostFormBuilder:
* - Header con gradiente navy
* - Layout de 2 columnas
* - Cards con borde izquierdo
* - Inputs compactos (form-control-sm)
*
* @package ROITheme\Admin\PostGrid\Infrastructure\Ui
*/
final class PostGridFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader();
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildShortcodeGuide();
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildMediaGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildLayoutGroup($componentId);
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-grid-3x3-gap me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Post Grid';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Grid de posts para listados, archivos y resultados de busqueda';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="post-grid">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('postGridEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('postGridShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('postGridShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// Checkboxes de visibilidad por tipo de página
$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', false);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
$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('postGridVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// Reglas de exclusion
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'postGrid');
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// Switches de contenido
$showThumbnail = $this->renderer->getFieldValue($componentId, 'content', 'show_thumbnail', true);
$html .= $this->buildSwitch('postGridShowThumbnail', 'Mostrar imagen destacada', 'bi-image', $showThumbnail);
$showExcerpt = $this->renderer->getFieldValue($componentId, 'content', 'show_excerpt', true);
$html .= $this->buildSwitch('postGridShowExcerpt', 'Mostrar extracto', 'bi-text-paragraph', $showExcerpt);
$showMeta = $this->renderer->getFieldValue($componentId, 'content', 'show_meta', true);
$html .= $this->buildSwitch('postGridShowMeta', 'Mostrar metadatos', 'bi-info-circle', $showMeta);
$showCategories = $this->renderer->getFieldValue($componentId, 'content', 'show_categories', true);
$html .= $this->buildSwitch('postGridShowCategories', 'Mostrar categorias', 'bi-folder', $showCategories);
$html .= ' <hr class="my-3">';
// Excerpt length
$excerptLength = $this->renderer->getFieldValue($componentId, 'content', 'excerpt_length', '20');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridExcerptLength" class="form-label small mb-1 fw-semibold">Longitud del extracto</label>';
$html .= ' <select id="postGridExcerptLength" class="form-select form-select-sm">';
$html .= ' <option value="10"' . ($excerptLength === '10' ? ' selected' : '') . '>10 palabras</option>';
$html .= ' <option value="15"' . ($excerptLength === '15' ? ' selected' : '') . '>15 palabras</option>';
$html .= ' <option value="20"' . ($excerptLength === '20' ? ' selected' : '') . '>20 palabras</option>';
$html .= ' <option value="25"' . ($excerptLength === '25' ? ' selected' : '') . '>25 palabras</option>';
$html .= ' <option value="30"' . ($excerptLength === '30' ? ' selected' : '') . '>30 palabras</option>';
$html .= ' </select>';
$html .= ' </div>';
// Read more text
$readMoreText = $this->renderer->getFieldValue($componentId, 'content', 'read_more_text', 'Leer mas');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridReadMoreText" class="form-label small mb-1 fw-semibold">Texto de leer mas</label>';
$html .= ' <input type="text" id="postGridReadMoreText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($readMoreText) . '">';
$html .= ' </div>';
// No posts message
$noPostsMessage = $this->renderer->getFieldValue($componentId, 'content', 'no_posts_message', 'No se encontraron publicaciones');
$html .= ' <div class="mb-0">';
$html .= ' <label for="postGridNoPostsMessage" class="form-label small mb-1 fw-semibold">Mensaje sin posts</label>';
$html .= ' <input type="text" id="postGridNoPostsMessage" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($noPostsMessage) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildLayoutGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-grid me-2" style="color: #FF8600;"></i>';
$html .= ' Disposicion';
$html .= ' </h5>';
// Columns desktop
$colsDesktop = $this->renderer->getFieldValue($componentId, 'layout', 'columns_desktop', '3');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridColumnsDesktop" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas escritorio';
$html .= ' </label>';
$html .= ' <select id="postGridColumnsDesktop" class="form-select form-select-sm">';
$html .= ' <option value="2"' . ($colsDesktop === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' <option value="3"' . ($colsDesktop === '3' ? ' selected' : '') . '>3 columnas</option>';
$html .= ' <option value="4"' . ($colsDesktop === '4' ? ' selected' : '') . '>4 columnas</option>';
$html .= ' </select>';
$html .= ' </div>';
// Columns tablet
$colsTablet = $this->renderer->getFieldValue($componentId, 'layout', 'columns_tablet', '2');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridColumnsTablet" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-tablet me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas tablet';
$html .= ' </label>';
$html .= ' <select id="postGridColumnsTablet" class="form-select form-select-sm">';
$html .= ' <option value="1"' . ($colsTablet === '1' ? ' selected' : '') . '>1 columna</option>';
$html .= ' <option value="2"' . ($colsTablet === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' <option value="3"' . ($colsTablet === '3' ? ' selected' : '') . '>3 columnas</option>';
$html .= ' </select>';
$html .= ' </div>';
// Columns mobile
$colsMobile = $this->renderer->getFieldValue($componentId, 'layout', 'columns_mobile', '1');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridColumnsMobile" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas movil';
$html .= ' </label>';
$html .= ' <select id="postGridColumnsMobile" class="form-select form-select-sm">';
$html .= ' <option value="1"' . ($colsMobile === '1' ? ' selected' : '') . '>1 columna</option>';
$html .= ' <option value="2"' . ($colsMobile === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' </select>';
$html .= ' </div>';
// Image position
$imagePosition = $this->renderer->getFieldValue($componentId, 'layout', 'image_position', 'top');
$html .= ' <div class="mb-0">';
$html .= ' <label for="postGridImagePosition" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-aspect-ratio me-1" style="color: #FF8600;"></i>';
$html .= ' Posicion de imagen';
$html .= ' </label>';
$html .= ' <select id="postGridImagePosition" class="form-select form-select-sm">';
$html .= ' <option value="top"' . ($imagePosition === 'top' ? ' selected' : '') . '>Arriba</option>';
$html .= ' <option value="left"' . ($imagePosition === 'left' ? ' selected' : '') . '>Izquierda</option>';
$html .= ' <option value="none"' . ($imagePosition === 'none' ? ' selected' : '') . '>Sin imagen</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildMediaGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-image me-2" style="color: #FF8600;"></i>';
$html .= ' Medios';
$html .= ' </h5>';
// Fallback image
$fallbackImage = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image', '');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridFallbackImage" class="form-label small mb-1 fw-semibold">URL imagen por defecto</label>';
$html .= ' <input type="url" id="postGridFallbackImage" class="form-control form-control-sm" ';
$html .= ' value="' . esc_url($fallbackImage) . '" placeholder="https://...">';
$html .= ' </div>';
// Fallback image alt
$fallbackImageAlt = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image_alt', 'Imagen por defecto');
$html .= ' <div class="mb-0">';
$html .= ' <label for="postGridFallbackImageAlt" class="form-label small mb-1 fw-semibold">Texto alternativo</label>';
$html .= ' <input type="text" id="postGridFallbackImageAlt" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fallbackImageAlt) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
// Heading level
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h3');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
$html .= ' <select id="postGridHeadingLevel" class="form-select form-select-sm">';
$html .= ' <option value="h2"' . ($headingLevel === 'h2' ? ' selected' : '') . '>H2</option>';
$html .= ' <option value="h3"' . ($headingLevel === 'h3' ? ' selected' : '') . '>H3</option>';
$html .= ' <option value="h4"' . ($headingLevel === 'h4' ? ' selected' : '') . '>H4</option>';
$html .= ' <option value="h5"' . ($headingLevel === 'h5' ? ' selected' : '') . '>H5</option>';
$html .= ' <option value="h6"' . ($headingLevel === 'h6' ? ' selected' : '') . '>H6</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardTitleSize = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_size', '1.1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="postGridCardTitleSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTitleSize) . '">';
$html .= ' </div>';
$cardTitleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_weight', '600');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="postGridCardTitleWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTitleWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$excerptSize = $this->renderer->getFieldValue($componentId, 'typography', 'excerpt_size', '0.9rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridExcerptSize" class="form-label small mb-1 fw-semibold">Tamano extracto</label>';
$html .= ' <input type="text" id="postGridExcerptSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($excerptSize) . '">';
$html .= ' </div>';
$metaSize = $this->renderer->getFieldValue($componentId, 'typography', 'meta_size', '0.8rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridMetaSize" class="form-label small mb-1 fw-semibold">Tamano metadatos</label>';
$html .= ' <input type="text" id="postGridMetaSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($metaSize) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Cards
$html .= ' <p class="small fw-semibold mb-2">Cards</p>';
$html .= ' <div class="row g-2 mb-3">';
$cardBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_bg_color', '#ffffff');
$html .= $this->buildColorPicker('postGridCardBgColor', 'Fondo', $cardBgColor);
$cardTitleColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_title_color', '#0E2337');
$html .= $this->buildColorPicker('postGridCardTitleColor', 'Titulo', $cardTitleColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardHoverBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_bg_color', '#f9fafb');
$html .= $this->buildColorPicker('postGridCardHoverBgColor', 'Fondo hover', $cardHoverBgColor);
$cardBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_border_color', '#e5e7eb');
$html .= $this->buildColorPicker('postGridCardBorderColor', 'Borde', $cardBorderColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardHoverBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_border_color', '#FF8600');
$html .= $this->buildColorPicker('postGridCardHoverBorderColor', 'Borde hover', $cardHoverBorderColor);
$excerptColor = $this->renderer->getFieldValue($componentId, 'colors', 'excerpt_color', '#6b7280');
$html .= $this->buildColorPicker('postGridExcerptColor', 'Extracto', $excerptColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$metaColor = $this->renderer->getFieldValue($componentId, 'colors', 'meta_color', '#9ca3af');
$html .= $this->buildColorPicker('postGridMetaColor', 'Metadatos', $metaColor);
$categoryBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_bg_color', '#FFF5EB');
$html .= $this->buildColorPicker('postGridCategoryBgColor', 'Fondo cat.', $categoryBgColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$categoryTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_text_color', '#FF8600');
$html .= $this->buildColorPicker('postGridCategoryTextColor', 'Texto cat.', $categoryTextColor);
$html .= ' </div>';
// Paginacion
$html .= ' <p class="small fw-semibold mb-2">Paginacion</p>';
$html .= ' <div class="row g-2 mb-3">';
$paginationColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_color', '#0E2337');
$html .= $this->buildColorPicker('postGridPaginationColor', 'Color', $paginationColor);
$paginationActiveBg = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_bg', '#FF8600');
$html .= $this->buildColorPicker('postGridPaginationActiveBg', 'Activo fondo', $paginationActiveBg);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$paginationActiveColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_color', '#ffffff');
$html .= $this->buildColorPicker('postGridPaginationActiveColor', 'Activo texto', $paginationActiveColor);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
// Separación entre cards
$html .= ' <p class="small text-muted mb-2">Separacion entre cards:</p>';
$html .= ' <div class="row g-2 mb-3">';
// Gap horizontal (entre columnas)
$gapHorizontal = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_horizontal', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridGapHorizontal" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>Horizontal';
$html .= ' </label>';
$html .= ' <select id="postGridGapHorizontal" class="form-select form-select-sm">';
$gapOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px', '40px', '48px'];
foreach ($gapOptions as $opt) {
$selected = ($gapHorizontal === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
// Gap vertical (entre filas)
$gapVertical = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_vertical', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridGapVertical" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-collapse me-1" style="color: #FF8600;"></i>Vertical';
$html .= ' </label>';
$html .= ' <select id="postGridGapVertical" class="form-select form-select-sm">';
foreach ($gapOptions as $opt) {
$selected = ($gapVertical === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
// Padding interno de cada card
$html .= ' <p class="small text-muted mb-2">Padding interno de card:</p>';
$html .= ' <div class="row g-2 mb-3">';
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '20px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-box me-1" style="color: #FF8600;"></i>Padding';
$html .= ' </label>';
$html .= ' <select id="postGridCardPadding" class="form-select form-select-sm">';
$paddingOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px'];
foreach ($paddingOptions as $opt) {
$selected = ($cardPadding === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' <div class="col-6"></div>';
$html .= ' </div>';
// Margenes de la seccion
$html .= ' <p class="small text-muted mb-2">Margenes de la seccion:</p>';
$html .= ' <div class="row g-2 mb-0">';
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrow-up me-1" style="color: #FF8600;"></i>Arriba';
$html .= ' </label>';
$html .= ' <select id="postGridSectionMarginTop" class="form-select form-select-sm">';
$marginOptions = ['0px', '8px', '16px', '24px', '32px', '48px', '64px'];
foreach ($marginOptions as $opt) {
$selected = ($sectionMarginTop === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '32px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrow-down me-1" style="color: #FF8600;"></i>Abajo';
$html .= ' </label>';
$html .= ' <select id="postGridSectionMarginBottom" class="form-select form-select-sm">';
foreach ($marginOptions as $opt) {
$selected = ($sectionMarginBottom === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$cardBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_border_radius', '0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardBorderRadius" class="form-label small mb-1 fw-semibold">Radio borde</label>';
$html .= ' <input type="text" id="postGridCardBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardBorderRadius) . '">';
$html .= ' </div>';
$imageBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'image_border_radius', '0.375rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridImageBorderRadius" class="form-label small mb-1 fw-semibold">Radio imagen</label>';
$html .= ' <input type="text" id="postGridImageBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($imageBorderRadius) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="mb-3">';
$cardTransition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_transition', 'all 0.3s ease');
$html .= ' <label for="postGridCardTransition" class="form-label small mb-1 fw-semibold">Transicion</label>';
$html .= ' <input type="text" id="postGridCardTransition" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTransition) . '">';
$html .= ' </div>';
$html .= ' <div class="mb-3">';
$cardShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_shadow', '0 1px 3px rgba(0,0,0,0.1)');
$html .= ' <label for="postGridCardShadow" class="form-label small mb-1 fw-semibold">Sombra normal</label>';
$html .= ' <input type="text" id="postGridCardShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardShadow) . '">';
$html .= ' </div>';
$html .= ' <div class="mb-0">';
$cardHoverShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_hover_shadow', '0 4px 12px rgba(0,0,0,0.15)');
$html .= ' <label for="postGridCardHoverShadow" class="form-label small mb-1 fw-semibold">Sombra hover</label>';
$html .= ' <input type="text" id="postGridCardHoverShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardHoverShadow) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-2">';
$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 .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
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 buildShortcodeGuide(): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-code-square me-2" style="color: #FF8600;"></i>';
$html .= ' Shortcode [roi_post_grid]';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">';
$html .= ' Usa este shortcode para insertar grids de posts en cualquier pagina o entrada. ';
$html .= ' Los estilos se heredan de la configuracion de este componente.';
$html .= ' </p>';
// Uso basico
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-1-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Uso basico (9 posts, 3 columnas)';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid]</code>';
$html .= ' </div>';
// Por categoria
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-2-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Filtrar por categoria';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid category="precios-unitarios"]</code>';
$html .= ' </div>';
// Personalizar cantidad y columnas
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-3-circle me-1" style="color: #FF8600;"></i>';
$html .= ' 6 posts en 2 columnas';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="6" columns="2"]</code>';
$html .= ' </div>';
// Con paginacion
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-4-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Con paginacion';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="12" show_pagination="true"]</code>';
$html .= ' </div>';
// Filtrar por tag
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-5-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Filtrar por etiqueta';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid tag="tutorial"]</code>';
$html .= ' </div>';
// Ejemplo completo
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-6-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Ejemplo completo';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.75rem;">';
$html .= ' <code class="text-warning">[roi_post_grid category="cursos" posts_per_page="6" columns="3" show_meta="false" show_categories="true"]</code>';
$html .= ' </div>';
// Tabla de atributos
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-list-check me-1" style="color: #FF8600;"></i>';
$html .= ' Atributos disponibles';
$html .= ' </p>';
$html .= ' <div class="table-responsive">';
$html .= ' <table class="table table-sm table-bordered small mb-0">';
$html .= ' <thead class="table-light">';
$html .= ' <tr><th>Atributo</th><th>Default</th><th>Descripcion</th></tr>';
$html .= ' </thead>';
$html .= ' <tbody>';
$html .= ' <tr><td><code>posts_per_page</code></td><td>9</td><td>Cantidad de posts</td></tr>';
$html .= ' <tr><td><code>columns</code></td><td>3</td><td>Columnas (1-4)</td></tr>';
$html .= ' <tr><td><code>category</code></td><td>-</td><td>Slug de categoria</td></tr>';
$html .= ' <tr><td><code>exclude_category</code></td><td>-</td><td>Excluir categoria</td></tr>';
$html .= ' <tr><td><code>tag</code></td><td>-</td><td>Slug de etiqueta</td></tr>';
$html .= ' <tr><td><code>author</code></td><td>-</td><td>ID o username</td></tr>';
$html .= ' <tr><td><code>orderby</code></td><td>date</td><td>date, title, rand</td></tr>';
$html .= ' <tr><td><code>order</code></td><td>DESC</td><td>DESC o ASC</td></tr>';
$html .= ' <tr><td><code>show_pagination</code></td><td>false</td><td>Mostrar paginacion</td></tr>';
$html .= ' <tr><td><code>show_thumbnail</code></td><td>true</td><td>Mostrar imagen</td></tr>';
$html .= ' <tr><td><code>show_excerpt</code></td><td>true</td><td>Mostrar extracto</td></tr>';
$html .= ' <tr><td><code>show_meta</code></td><td>true</td><td>Fecha y autor</td></tr>';
$html .= ' <tr><td><code>show_categories</code></td><td>true</td><td>Badges categoria</td></tr>';
$html .= ' <tr><td><code>excerpt_length</code></td><td>20</td><td>Palabras extracto</td></tr>';
$html .= ' <tr><td><code>exclude_posts</code></td><td>-</td><td>IDs separados por coma</td></tr>';
$html .= ' <tr><td><code>offset</code></td><td>0</td><td>Saltar N posts</td></tr>';
$html .= ' <tr><td><code>id</code></td><td>-</td><td>ID unico (multiples grids)</td></tr>';
$html .= ' <tr><td><code>class</code></td><td>-</td><td>Clase CSS adicional</td></tr>';
$html .= ' </tbody>';
$html .= ' </table>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -33,6 +33,8 @@ final class FieldMapperProvider
'Footer',
'ThemeSettings',
'AdsensePlacement',
'ArchiveHeader',
'PostGrid',
];
public function __construct(

View File

@@ -12,7 +12,7 @@
BASE STYLES - Todas las tablas genéricas
======================================== */
.post-content table:not(.analisis table) {
.post-content table:not(.analisis table):not(.desglose table) {
width: 100%;
border-collapse: collapse;
margin: 2rem auto;
@@ -23,9 +23,9 @@
}
/* Header styles - VERY OBVIOUS */
.post-content table:not(.analisis table) thead tr:first-child th,
.post-content table:not(.analisis table) tbody tr:first-child td,
.post-content table:not(.analisis table) tr:first-child td {
.post-content table:not(.analisis table):not(.desglose table) thead tr:first-child th,
.post-content table:not(.analisis table):not(.desglose table) tbody tr:first-child td,
.post-content table:not(.analisis table):not(.desglose table) tr:first-child td {
font-weight: 700;
text-align: center;
padding: 1.25rem 1rem;
@@ -34,7 +34,7 @@
}
/* Body cells */
.post-content table:not(.analisis table) tbody tr:not(:first-child) td {
.post-content table:not(.analisis table):not(.desglose table) tbody tr:not(:first-child) td {
padding: 0.875rem 1rem;
border: 1px solid var(--color-neutral-100);
text-align: left;

View File

@@ -1,99 +0,0 @@
/**
* Auto-detectar y agregar clases a filas especiales de tablas APU
*
* Este script detecta automáticamente filas especiales en tablas .desglose y .analisis
* y les agrega las clases CSS correspondientes para que se apliquen los estilos correctos.
*
* Detecta:
* - Section headers: Material, Mano de Obra, Herramienta, Equipo
* - Subtotal rows: Filas que empiezan con "Suma de"
* - Total row: Costo Directo
*
* @package Apus_Theme
* @since 1.0.0
*/
(function() {
'use strict';
/**
* Agrega clases a filas especiales de tablas APU
*/
function applyApuTableClasses() {
// Buscar todas las tablas con clase .desglose o .analisis
const tables = document.querySelectorAll('.desglose table, .analisis table');
if (tables.length === 0) {
return; // No hay tablas APU en esta página
}
let classesAdded = 0;
tables.forEach(function(table) {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(function(row) {
// Evitar procesar filas que ya tienen clase
if (row.classList.contains('section-header') ||
row.classList.contains('subtotal-row') ||
row.classList.contains('total-row')) {
return;
}
const secondCell = row.querySelector('td:nth-child(2)');
if (!secondCell) {
return; // Fila sin segunda celda
}
const text = secondCell.textContent.trim();
// Detectar section headers
if (text === 'Material' ||
text === 'Mano de Obra' ||
text === 'Herramienta' ||
text === 'Equipo' ||
text === 'MATERIAL' ||
text === 'MANO DE OBRA' ||
text === 'HERRAMIENTA' ||
text === 'EQUIPO') {
row.classList.add('section-header');
classesAdded++;
return;
}
// Detectar subtotales (cualquier variación de "Suma de")
if (text.toLowerCase().startsWith('suma de ') ||
text.toLowerCase().startsWith('subtotal ')) {
row.classList.add('subtotal-row');
classesAdded++;
return;
}
// Detectar total final
if (text === 'Costo Directo' ||
text === 'COSTO DIRECTO' ||
text === 'Total' ||
text === 'TOTAL' ||
text === 'Costo directo') {
row.classList.add('total-row');
classesAdded++;
return;
}
});
});
// Log para debugging (solo en desarrollo)
if (classesAdded > 0 && window.console) {
console.log('[APU Tables] Clases agregadas automáticamente: ' + classesAdded);
}
}
// Ejecutar cuando el DOM esté listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyApuTableClasses);
} else {
// DOM ya está listo
applyApuTableClasses();
}
})();

View File

@@ -1,342 +0,0 @@
/**
* Header Navigation JavaScript
*
* This file handles:
* - Mobile hamburger menu toggle
* - Sticky header behavior
* - Smooth scroll to anchors (optional)
* - Accessibility features (keyboard navigation, ARIA attributes)
* - Body scroll locking when mobile menu is open
*
* @package ROI_Theme
* @since 1.0.0
*/
(function() {
'use strict';
/**
* Initialize on DOM ready
*/
function init() {
setupMobileMenu();
setupStickyHeader();
setupSmoothScroll();
setupKeyboardNavigation();
}
/**
* Mobile Menu Functionality
*/
function setupMobileMenu() {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
const mobileMenuClose = document.getElementById('mobile-menu-close');
if (!mobileMenuToggle || !mobileMenu || !mobileMenuOverlay) {
return;
}
// Open mobile menu
mobileMenuToggle.addEventListener('click', function() {
openMobileMenu();
});
// Close mobile menu via close button
if (mobileMenuClose) {
mobileMenuClose.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu via overlay click
mobileMenuOverlay.addEventListener('click', function() {
closeMobileMenu();
});
// Close mobile menu on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && mobileMenu.classList.contains('active')) {
closeMobileMenu();
mobileMenuToggle.focus();
}
});
// Close mobile menu when clicking a menu link
const mobileMenuLinks = mobileMenu.querySelectorAll('a');
mobileMenuLinks.forEach(function(link) {
link.addEventListener('click', function() {
closeMobileMenu();
});
});
// Handle window resize - close mobile menu if switching to desktop
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
if (window.innerWidth >= 768 && mobileMenu.classList.contains('active')) {
closeMobileMenu();
}
}, 250);
});
}
/**
* Open mobile menu
*/
function openMobileMenu() {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
// Add active classes
mobileMenu.classList.add('active');
mobileMenuOverlay.classList.add('active');
document.body.classList.add('mobile-menu-open');
// Update ARIA attributes
mobileMenuToggle.setAttribute('aria-expanded', 'true');
mobileMenu.setAttribute('aria-hidden', 'false');
mobileMenuOverlay.setAttribute('aria-hidden', 'false');
// Focus trap - focus first menu item
const firstMenuItem = mobileMenu.querySelector('a');
if (firstMenuItem) {
setTimeout(function() {
firstMenuItem.focus();
}, 300);
}
}
/**
* Close mobile menu
*/
function closeMobileMenu() {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
// Remove active classes
mobileMenu.classList.remove('active');
mobileMenuOverlay.classList.remove('active');
document.body.classList.remove('mobile-menu-open');
// Update ARIA attributes
mobileMenuToggle.setAttribute('aria-expanded', 'false');
mobileMenu.setAttribute('aria-hidden', 'true');
mobileMenuOverlay.setAttribute('aria-hidden', 'true');
}
/**
* Sticky Header Behavior
*/
function setupStickyHeader() {
const header = document.getElementById('masthead');
if (!header) {
return;
}
let lastScrollTop = 0;
let scrollThreshold = 100;
window.addEventListener('scroll', function() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// Add/remove scrolled class based on scroll position
if (scrollTop > scrollThreshold) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
lastScrollTop = scrollTop;
}, { passive: true });
}
/**
* Smooth Scroll to Anchors (Optional)
*/
function setupSmoothScroll() {
// Check if user prefers reduced motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
return;
}
// Get all anchor links
const anchorLinks = document.querySelectorAll('a[href^="#"]');
anchorLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
const href = this.getAttribute('href');
// Skip if href is just "#"
if (href === '#') {
return;
}
const target = document.querySelector(href);
if (target) {
e.preventDefault();
// Get header height for offset
const header = document.getElementById('masthead');
const headerHeight = header ? header.offsetHeight : 0;
const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - headerHeight - 20;
window.scrollTo({
top: targetPosition,
behavior: prefersReducedMotion ? 'auto' : 'smooth'
});
// Update URL hash
if (history.pushState) {
history.pushState(null, null, href);
}
// Focus target element for accessibility
target.setAttribute('tabindex', '-1');
target.focus();
}
});
});
}
/**
* Keyboard Navigation for Menus
*/
function setupKeyboardNavigation() {
const menuItems = document.querySelectorAll('.primary-menu > li, .mobile-primary-menu > li');
menuItems.forEach(function(item) {
const link = item.querySelector('a');
const submenu = item.querySelector('.sub-menu');
if (!link || !submenu) {
return;
}
// Open submenu on Enter/Space
link.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
if (submenu) {
e.preventDefault();
toggleSubmenu(item, submenu);
}
}
// Close submenu on Escape
if (e.key === 'Escape') {
closeSubmenu(item, submenu);
link.focus();
}
});
// Close submenu when focus leaves
const submenuLinks = submenu.querySelectorAll('a');
if (submenuLinks.length > 0) {
const lastSubmenuLink = submenuLinks[submenuLinks.length - 1];
lastSubmenuLink.addEventListener('keydown', function(e) {
if (e.key === 'Tab' && !e.shiftKey) {
closeSubmenu(item, submenu);
}
});
}
});
}
/**
* Toggle submenu visibility
*/
function toggleSubmenu(item, submenu) {
const isExpanded = item.classList.contains('submenu-open');
if (isExpanded) {
closeSubmenu(item, submenu);
} else {
openSubmenu(item, submenu);
}
}
/**
* Open submenu
*/
function openSubmenu(item, submenu) {
item.classList.add('submenu-open');
submenu.setAttribute('aria-hidden', 'false');
const firstLink = submenu.querySelector('a');
if (firstLink) {
firstLink.focus();
}
}
/**
* Close submenu
*/
function closeSubmenu(item, submenu) {
item.classList.remove('submenu-open');
submenu.setAttribute('aria-hidden', 'true');
}
/**
* Trap focus within mobile menu when open
*/
function setupFocusTrap() {
const mobileMenu = document.getElementById('mobile-menu');
if (!mobileMenu) {
return;
}
document.addEventListener('keydown', function(e) {
if (!mobileMenu.classList.contains('active')) {
return;
}
if (e.key === 'Tab') {
const focusableElements = mobileMenu.querySelectorAll(
'a, button, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
});
}
/**
* Initialize focus trap
*/
setupFocusTrap();
/**
* Initialize when DOM is ready
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -1,294 +0,0 @@
<?php
/**
* Related Posts Functionality
*
* Provides configurable related posts functionality with Bootstrap grid support.
*
* @package ROI_Theme
* @since 1.0.0
*/
// Exit if accessed directly
if (!defined('ABSPATH')) {
exit;
}
/**
* Get related posts based on categories
*
* @param int $post_id The post ID to get related posts for
* @return WP_Query|false Query object with related posts or false if none found
*/
function roi_get_related_posts($post_id) {
// Get post categories
$categories = wp_get_post_categories($post_id);
if (empty($categories)) {
return false;
}
// Get number of posts to display (default: 3)
$posts_per_page = get_option('roi_related_posts_count', 3);
// Query arguments
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $posts_per_page,
'post__not_in' => array($post_id),
'category__in' => $categories,
'orderby' => 'rand',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
);
// Allow filtering of query args
$args = apply_filters('roi_related_posts_args', $args, $post_id);
// Get related posts
$related_query = new WP_Query($args);
return $related_query->have_posts() ? $related_query : false;
}
/**
* Display related posts section
*
* @param int|null $post_id Optional. Post ID. Default is current post.
* @return void
*/
function roi_display_related_posts($post_id = null) {
// Get post ID
if (!$post_id) {
$post_id = get_the_ID();
}
// Check if related posts are enabled
$enabled = get_option('roi_related_posts_enabled', true);
if (!$enabled) {
return;
}
// Get related posts
$related_query = roi_get_related_posts($post_id);
if (!$related_query) {
return;
}
// Get configuration options
$title = get_option('roi_related_posts_title', __('Related Posts', 'roi-theme'));
$columns = get_option('roi_related_posts_columns', 3);
$show_excerpt = get_option('roi_related_posts_show_excerpt', true);
$show_date = get_option('roi_related_posts_show_date', true);
$show_category = get_option('roi_related_posts_show_category', true);
$excerpt_length = get_option('roi_related_posts_excerpt_length', 20);
$background_colors = get_option('roi_related_posts_bg_colors', array(
'#1a73e8', // Blue
'#e91e63', // Pink
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#00bcd4', // Cyan
));
// Calculate Bootstrap column class
$col_class = roi_get_column_class($columns);
// Start output
?>
<section class="related-posts-section">
<div class="related-posts-container">
<?php if ($title) : ?>
<h2 class="related-posts-title"><?php echo esc_html($title); ?></h2>
<?php endif; ?>
<div class="row g-4">
<?php
$color_index = 0;
while ($related_query->have_posts()) :
$related_query->the_post();
$has_thumbnail = has_post_thumbnail();
// Get background color for posts without image
$bg_color = $background_colors[$color_index % count($background_colors)];
$color_index++;
?>
<div class="<?php echo esc_attr($col_class); ?>">
<article class="related-post-card <?php echo $has_thumbnail ? 'has-thumbnail' : 'no-thumbnail'; ?>">
<a href="<?php the_permalink(); ?>" class="related-post-link">
<?php if ($has_thumbnail) : ?>
<!-- Card with Image -->
<div class="related-post-thumbnail">
<?php
the_post_thumbnail('roi-thumbnail', array(
'alt' => the_title_attribute(array('echo' => false)),
'loading' => 'lazy',
));
?>
<?php if ($show_category) : ?>
<?php
$categories = get_the_category();
if (!empty($categories)) :
$category = $categories[0];
?>
<span class="related-post-category">
<?php echo esc_html($category->name); ?>
</span>
<?php endif; ?>
<?php endif; ?>
</div>
<?php else : ?>
<!-- Card without Image - Color Background -->
<div class="related-post-no-image" style="background-color: <?php echo esc_attr($bg_color); ?>;">
<div class="related-post-no-image-content">
<h3 class="related-post-no-image-title">
<?php the_title(); ?>
</h3>
<?php if ($show_category) : ?>
<?php
$categories = get_the_category();
if (!empty($categories)) :
$category = $categories[0];
?>
<span class="related-post-category no-image">
<?php echo esc_html($category->name); ?>
</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="related-post-content">
<?php if ($has_thumbnail) : ?>
<h3 class="related-post-title">
<?php the_title(); ?>
</h3>
<?php endif; ?>
<?php if ($show_excerpt && $excerpt_length > 0) : ?>
<div class="related-post-excerpt">
<?php echo wp_trim_words(get_the_excerpt(), $excerpt_length, '...'); ?>
</div>
<?php endif; ?>
<?php if ($show_date) : ?>
<div class="related-post-meta">
<time class="related-post-date" datetime="<?php echo esc_attr(get_the_date('c')); ?>">
<?php echo esc_html(get_the_date()); ?>
</time>
</div>
<?php endif; ?>
</div>
</a>
</article>
</div>
<?php endwhile; ?>
</div><!-- .row -->
</div><!-- .related-posts-container -->
</section><!-- .related-posts-section -->
<?php
// Reset post data
wp_reset_postdata();
}
/**
* Get Bootstrap column class based on number of columns
*
* @param int $columns Number of columns (1-4)
* @return string Bootstrap column classes
*/
function roi_get_column_class($columns) {
$columns = absint($columns);
switch ($columns) {
case 1:
return 'col-12';
case 2:
return 'col-12 col-md-6';
case 3:
return 'col-12 col-sm-6 col-lg-4';
case 4:
return 'col-12 col-sm-6 col-lg-3';
default:
return 'col-12 col-sm-6 col-lg-4'; // Default to 3 columns
}
}
/**
* Hook related posts display after post content
*/
function roi_hook_related_posts() {
if (is_single() && !is_attachment()) {
roi_display_related_posts();
}
}
add_action('roi_after_post_content', 'roi_hook_related_posts');
/**
* Enqueue related posts styles
*/
function roi_enqueue_related_posts_styles() {
if (is_single() && !is_attachment()) {
$enabled = get_option('roi_related_posts_enabled', true);
if ($enabled) {
wp_enqueue_style(
'roirelated-posts',
get_template_directory_uri() . '/Assets/Css/related-posts.css',
array('roibootstrap'),
ROI_VERSION,
'all'
);
}
}
}
add_action('wp_enqueue_scripts', 'roi_enqueue_related_posts_styles');
/**
* Register related posts settings
* These can be configured via theme options or customizer
*/
function roi_related_posts_default_options() {
// Set default options if they don't exist
$defaults = array(
'roi_related_posts_enabled' => true,
'roi_related_posts_title' => __('Related Posts', 'roi-theme'),
'roi_related_posts_count' => 3,
'roi_related_posts_columns' => 3,
'roi_related_posts_show_excerpt' => true,
'roi_related_posts_excerpt_length' => 20,
'roi_related_posts_show_date' => true,
'roi_related_posts_show_category' => true,
'roi_related_posts_bg_colors' => array(
'#1a73e8', // Blue
'#e91e63', // Pink
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#00bcd4', // Cyan
),
);
foreach ($defaults as $option => $value) {
if (get_option($option) === false) {
add_option($option, $value);
}
}
}
add_action('after_setup_theme', 'roi_related_posts_default_options');

View File

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ArchiveHeader\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;
/**
* ArchiveHeaderRenderer - Renderiza cabecera dinamica para paginas de archivo
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Archive Header
*
* CARACTERISTICAS:
* - Deteccion automatica del tipo de archivo (categoria, tag, autor, fecha, busqueda)
* - Titulo y descripcion dinamicos
* - Contador de posts opcional
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\ArchiveHeader\Infrastructure\Ui
*/
final class ArchiveHeaderRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'archive-header';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$behavior = $data['behavior'] ?? [];
$cssRules = [];
// Container
$marginTop = $spacing['margin_top'] ?? '2rem';
$marginBottom = $spacing['margin_bottom'] ?? '2rem';
$padding = $spacing['padding'] ?? '1.5rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header', [
'margin-top' => $marginTop,
'margin-bottom' => $marginBottom,
'padding' => $padding,
]);
// Sticky behavior
$isSticky = $behavior['is_sticky'] ?? false;
$isSticky = $isSticky === true || $isSticky === '1' || $isSticky === 1;
if ($isSticky) {
$stickyOffset = $behavior['sticky_offset'] ?? '0';
$cssRules[] = $this->cssGenerator->generate('.archive-header', [
'position' => 'sticky',
'top' => $stickyOffset,
'z-index' => '100',
'background' => '#ffffff',
]);
}
// Title
$titleColor = $colors['title_color'] ?? '#0E2337';
$titleSize = $typography['title_size'] ?? '2rem';
$titleWeight = $typography['title_weight'] ?? '700';
$titleMarginBottom = $spacing['title_margin_bottom'] ?? '0.5rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header__title', [
'color' => $titleColor,
'font-size' => $titleSize,
'font-weight' => $titleWeight,
'margin-bottom' => $titleMarginBottom,
'line-height' => '1.2',
]);
// Prefix
$prefixColor = $colors['prefix_color'] ?? '#6b7280';
$cssRules[] = $this->cssGenerator->generate('.archive-header__prefix', [
'color' => $prefixColor,
'font-weight' => '400',
]);
// Description
$descColor = $colors['description_color'] ?? '#6b7280';
$descSize = $typography['description_size'] ?? '1rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header__description', [
'color' => $descColor,
'font-size' => $descSize,
'margin-top' => '0.5rem',
'line-height' => '1.6',
]);
// Post count badge
$countBgColor = $colors['count_bg_color'] ?? '#FF8600';
$countTextColor = $colors['count_text_color'] ?? '#ffffff';
$countSize = $typography['count_size'] ?? '0.875rem';
$countPadding = $spacing['count_padding'] ?? '0.25rem 0.75rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header__count', [
'background-color' => $countBgColor,
'color' => $countTextColor,
'font-size' => $countSize,
'padding' => $countPadding,
'border-radius' => '9999px',
'font-weight' => '500',
'display' => 'inline-block',
'margin-left' => '0.75rem',
]);
return implode("\n", $cssRules);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$typography = $data['typography'] ?? [];
$headingLevel = $typography['heading_level'] ?? 'h1';
$showPostCount = $content['show_post_count'] ?? true;
$showPostCount = $showPostCount === true || $showPostCount === '1' || $showPostCount === 1;
$showDescription = $content['show_description'] ?? true;
$showDescription = $showDescription === true || $showDescription === '1' || $showDescription === 1;
// Get context-specific title and description
$titleData = $this->getContextualTitle($content);
$title = $titleData['title'];
$prefix = $titleData['prefix'];
$description = $showDescription ? $titleData['description'] : '';
// Get post count
$postCount = $this->getPostCount();
$countSingular = $content['posts_count_singular'] ?? 'publicacion';
$countPlural = $content['posts_count_plural'] ?? 'publicaciones';
$countText = $postCount === 1 ? $countSingular : $countPlural;
$containerClass = 'archive-header';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
// Title with optional prefix
$html .= sprintf('<%s class="archive-header__title">', esc_attr($headingLevel));
if (!empty($prefix)) {
$html .= sprintf(
'<span class="archive-header__prefix">%s</span> ',
esc_html($prefix)
);
}
$html .= esc_html($title);
// Post count badge
if ($showPostCount && $postCount > 0) {
$html .= sprintf(
'<span class="archive-header__count">%d %s</span>',
$postCount,
esc_html($countText)
);
}
$html .= sprintf('</%s>', esc_attr($headingLevel));
// Description
if (!empty($description)) {
$html .= sprintf(
'<p class="archive-header__description">%s</p>',
esc_html($description)
);
}
$html .= '</div>';
return $html;
}
/**
* Get contextual title based on current page type
*
* @param array $content Content settings from schema
* @return array{title: string, prefix: string, description: string}
*/
private function getContextualTitle(array $content): array
{
$title = '';
$prefix = '';
$description = '';
if (is_category()) {
$prefix = $content['category_prefix'] ?? 'Categoria:';
$title = single_cat_title('', false) ?: '';
$description = category_description() ?: '';
} elseif (is_tag()) {
$prefix = $content['tag_prefix'] ?? 'Etiqueta:';
$title = single_tag_title('', false) ?: '';
$description = tag_description() ?: '';
} elseif (is_author()) {
$prefix = $content['author_prefix'] ?? 'Articulos de:';
$title = get_the_author() ?: '';
$description = get_the_author_meta('description') ?: '';
} elseif (is_date()) {
$prefix = $content['date_prefix'] ?? 'Archivo:';
$title = $this->getDateArchiveTitle();
$description = '';
} elseif (is_search()) {
$prefix = $content['search_prefix'] ?? 'Resultados para:';
$title = get_search_query() ?: '';
$description = '';
} elseif (is_home()) {
$prefix = '';
$title = $content['blog_title'] ?? 'Blog';
$description = '';
} elseif (is_archive()) {
$prefix = '';
$title = get_the_archive_title() ?: 'Archivo';
$description = get_the_archive_description() ?: '';
} else {
$prefix = '';
$title = $content['blog_title'] ?? 'Blog';
$description = '';
}
return [
'title' => $title,
'prefix' => $prefix,
'description' => strip_tags($description),
];
}
/**
* Get formatted title for date archives
*/
private function getDateArchiveTitle(): string
{
if (is_day()) {
return get_the_date();
} elseif (is_month()) {
return get_the_date('F Y');
} elseif (is_year()) {
return get_the_date('Y');
}
return get_the_archive_title() ?: '';
}
/**
* Get total post count for current query
*/
private function getPostCount(): int
{
global $wp_query;
return $wp_query->found_posts ?? 0;
}
}

View File

@@ -0,0 +1,651 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\PostGrid\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;
/**
* PostGridRenderer - Renderiza grid de posts del loop principal de WordPress
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Post Grid
*
* DIFERENCIA CON RelatedPostRenderer:
* - PostGrid usa global $wp_query (loop principal)
* - RelatedPost crea su propio WP_Query
*
* CARACTERISTICAS:
* - Grid responsive de cards con imagen, excerpt y meta
* - Usa loop principal de WordPress (no crea queries propias)
* - Paginacion nativa de WordPress
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\PostGrid\Infrastructure\Ui
*/
final class PostGridRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'post-grid';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
global $wp_query;
// Si no hay posts, mostrar mensaje
if (!have_posts()) {
$noPostsMessage = $data['content']['no_posts_message'] ?? 'No se encontraron publicaciones';
return $this->renderNoPostsMessage($noPostsMessage, $visibilityClass, $data);
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function renderNoPostsMessage(string $message, string $visibilityClass, array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
$textColor = $colors['excerpt_color'] ?? '#6b7280';
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
$padding = $spacing['card_padding'] ?? '1.25rem';
$css = $this->cssGenerator->generate('.post-grid-no-posts', [
'background-color' => $bgColor,
'color' => $textColor,
'border' => "1px solid {$borderColor}",
'border-radius' => '0.5rem',
'padding' => '2rem',
'text-align' => 'center',
]);
$containerClass = 'post-grid-no-posts';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf(
'<div class="%s"><p class="mb-0">%s</p></div>',
esc_attr($containerClass),
esc_html($message)
);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$typography = $data['typography'] ?? [];
$layout = $data['layout'] ?? [];
$cssRules = [];
// Colores
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
$metaColor = $colors['meta_color'] ?? '#9ca3af';
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
$paginationColor = $colors['pagination_color'] ?? '#0E2337';
$paginationActiveBg = $colors['pagination_active_bg'] ?? '#FF8600';
$paginationActiveColor = $colors['pagination_active_color'] ?? '#ffffff';
// Spacing
$gapHorizontal = $spacing['gap_horizontal'] ?? '24px';
$gapVertical = $spacing['gap_vertical'] ?? '24px';
$cardPadding = $spacing['card_padding'] ?? '20px';
$sectionMarginTop = $spacing['section_margin_top'] ?? '0px';
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '32px';
// Visual effects
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
$imageBorderRadius = $effects['image_border_radius'] ?? '0.375rem';
// Typography
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
$metaSize = $typography['meta_size'] ?? '0.8rem';
// Container
$cssRules[] = $this->cssGenerator->generate('.post-grid', [
'margin-top' => $sectionMarginTop,
'margin-bottom' => $sectionMarginBottom,
]);
// Row: usar display flex con gap, quitar margins/paddings de Bootstrap
$cssRules[] = ".post-grid .row {
display: flex;
flex-wrap: wrap;
column-gap: {$gapHorizontal};
row-gap: {$gapVertical};
margin: 0;
padding: 0;
}";
// Columnas: quitar padding de Bootstrap y margin-bottom
$cssRules[] = ".post-grid .post-card-col {
padding: 0;
margin: 0;
}";
// Card base - sin margin extra
$cssRules[] = ".post-grid .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
border-radius: {$cardBorderRadius};
box-shadow: {$cardShadow};
transition: {$cardTransition};
height: 100%;
overflow: hidden;
margin: 0;
}";
// Card hover
$cssRules[] = ".post-grid .card:hover {
background: {$cardHoverBgColor};
border-color: {$cardHoverBorderColor};
box-shadow: {$cardHoverShadow};
transform: translateY(-2px);
}";
// Card body
$cssRules[] = $this->cssGenerator->generate('.post-grid .card-body', [
'padding' => $cardPadding,
]);
// Card image
$cssRules[] = ".post-grid .card-img-top {
border-radius: {$imageBorderRadius} {$imageBorderRadius} 0 0;
object-fit: cover;
width: 100%;
height: 200px;
}";
// Card title
$cssRules[] = ".post-grid .card-title {
color: {$cardTitleColor};
font-size: {$cardTitleSize};
font-weight: {$cardTitleWeight};
line-height: 1.4;
margin-bottom: 0.75rem;
}";
// Card title hover
$cssRules[] = ".post-grid a:hover .card-title {
color: {$cardHoverBorderColor};
}";
// Excerpt
$cssRules[] = ".post-grid .card-text {
color: {$excerptColor};
font-size: {$excerptSize};
line-height: 1.6;
}";
// Meta
$cssRules[] = ".post-grid .post-meta {
color: {$metaColor};
font-size: {$metaSize};
}";
// Categories
$cssRules[] = ".post-grid .post-category {
background: {$categoryBgColor};
color: {$categoryTextColor};
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
text-decoration: none;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}";
$cssRules[] = ".post-grid .post-category:hover {
background: {$categoryTextColor};
color: #ffffff;
}";
// Pagination
$cssRules[] = ".post-grid .pagination {
margin-top: 2rem;
}";
$cssRules[] = ".post-grid .page-link {
color: {$paginationColor};
border: 1px solid {$cardBorderColor};
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
}";
$cssRules[] = ".post-grid .page-link:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$paginationActiveBg};
color: {$paginationActiveBg};
}";
$cssRules[] = ".post-grid .page-item.active .page-link,
.post-grid .nav-links .current {
background-color: {$paginationActiveBg};
border-color: {$paginationActiveBg};
color: {$paginationActiveColor};
}";
// WordPress pagination classes
$cssRules[] = ".post-grid .nav-links {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 2rem;
}";
$cssRules[] = ".post-grid .nav-links a,
.post-grid .nav-links span {
color: {$paginationColor};
border: 1px solid {$cardBorderColor};
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
}";
$cssRules[] = ".post-grid .nav-links a:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$paginationActiveBg};
color: {$paginationActiveBg};
}";
// Layout responsive columns
$colsDesktop = $layout['columns_desktop'] ?? '3';
$colsTablet = $layout['columns_tablet'] ?? '2';
$colsMobile = $layout['columns_mobile'] ?? '1';
// Mobile (1 col = no gap needed)
$mobileWidth = $this->getColumnWidth($colsMobile, $gapHorizontal);
$cssRules[] = "@media (max-width: 575.98px) {
.post-grid .post-card-col {
flex: 0 0 {$mobileWidth};
max-width: {$mobileWidth};
}
}";
// Tablet
$tabletWidth = $this->getColumnWidth($colsTablet, $gapHorizontal);
$cssRules[] = "@media (min-width: 576px) and (max-width: 991.98px) {
.post-grid .post-card-col {
flex: 0 0 {$tabletWidth};
max-width: {$tabletWidth};
}
}";
// Desktop
$desktopWidth = $this->getColumnWidth($colsDesktop, $gapHorizontal);
$cssRules[] = "@media (min-width: 992px) {
.post-grid .post-card-col {
flex: 0 0 {$desktopWidth};
max-width: {$desktopWidth};
}
}";
return implode("\n", $cssRules);
}
/**
* Calcula el ancho de columna considerando el gap
*
* Con gap en flexbox, el ancho debe ser:
* (100% - (n-1)*gap) / n
*
* @param string $cols Número de columnas
* @param string $gap Valor del gap (ej: '1.5rem', '24px')
* @return string Valor CSS con calc() si hay gap
*/
private function getColumnWidth(string $cols, string $gap): string
{
$colCount = (int)$cols;
if ($colCount <= 0) {
$colCount = 1;
}
// Si es 1 columna, no hay gap entre columnas
if ($colCount === 1) {
return '100%';
}
// Número de gaps = columnas - 1
$gapCount = $colCount - 1;
// calc((100% - (n-1)*gap) / n)
return sprintf('calc((100%% - %d * %s) / %d)', $gapCount, $gap, $colCount);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$typography = $data['typography'] ?? [];
$media = $data['media'] ?? [];
$layout = $data['layout'] ?? [];
$showThumbnail = $this->toBool($content['show_thumbnail'] ?? true);
$showExcerpt = $this->toBool($content['show_excerpt'] ?? true);
$showMeta = $this->toBool($content['show_meta'] ?? true);
$showCategories = $this->toBool($content['show_categories'] ?? true);
$excerptLength = (int)($content['excerpt_length'] ?? 20);
$readMoreText = $content['read_more_text'] ?? 'Leer mas';
$headingLevel = $typography['heading_level'] ?? 'h3';
$fallbackImage = $media['fallback_image'] ?? '';
$fallbackImageAlt = $media['fallback_image_alt'] ?? 'Imagen por defecto';
$imagePosition = $layout['image_position'] ?? 'top';
$containerClass = 'post-grid';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= '<div class="row">';
while (have_posts()) {
the_post();
$html .= $this->buildCardHTML(
$showThumbnail,
$showExcerpt,
$showMeta,
$showCategories,
$excerptLength,
$readMoreText,
$headingLevel,
$fallbackImage,
$fallbackImageAlt,
$imagePosition
);
}
$html .= '</div>';
// Paginacion nativa de WordPress
$html .= '<div class="pagination-wrapper">';
$html .= $this->buildPaginationHTML();
$html .= '</div>';
$html .= '</div>';
wp_reset_postdata();
return $html;
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
private function buildCardHTML(
bool $showThumbnail,
bool $showExcerpt,
bool $showMeta,
bool $showCategories,
int $excerptLength,
string $readMoreText,
string $headingLevel,
string $fallbackImage,
string $fallbackImageAlt,
string $imagePosition
): string {
$permalink = get_permalink();
$title = get_the_title();
$html = '<div class="post-card-col">';
$html .= sprintf(
'<a href="%s" class="text-decoration-none">',
esc_url($permalink)
);
$cardClass = 'card h-100';
if ($imagePosition === 'left') {
$cardClass .= ' flex-row';
}
$html .= sprintf('<div class="%s">', esc_attr($cardClass));
// Imagen
if ($showThumbnail && $imagePosition !== 'none') {
$html .= $this->buildImageHTML($fallbackImage, $fallbackImageAlt, $imagePosition);
}
$html .= '<div class="card-body">';
// Categorias
if ($showCategories) {
$html .= $this->buildCategoriesHTML();
}
// Titulo
$html .= sprintf(
'<%s class="card-title">%s</%s>',
esc_attr($headingLevel),
esc_html($title),
esc_attr($headingLevel)
);
// Meta
if ($showMeta) {
$html .= $this->buildMetaHTML();
}
// Excerpt
if ($showExcerpt) {
$html .= $this->buildExcerptHTML($excerptLength);
}
$html .= '</div>'; // card-body
$html .= '</div>'; // card
$html .= '</a>';
$html .= '</div>'; // col
return $html;
}
private function buildImageHTML(string $fallbackImage, string $fallbackImageAlt, string $imagePosition): string
{
if (has_post_thumbnail()) {
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
return get_the_post_thumbnail(
null,
'medium_large',
['class' => $imageClass, 'loading' => 'lazy']
);
}
if (!empty($fallbackImage)) {
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
return sprintf(
'<img src="%s" alt="%s" class="%s" loading="lazy">',
esc_url($fallbackImage),
esc_attr($fallbackImageAlt),
esc_attr($imageClass)
);
}
return '';
}
private function buildCategoriesHTML(): string
{
$categories = get_the_category();
if (empty($categories)) {
return '';
}
$html = '<div class="post-categories mb-2">';
foreach (array_slice($categories, 0, 2) as $category) {
$html .= sprintf(
'<span class="post-category">%s</span>',
esc_html($category->name)
);
}
$html .= '</div>';
return $html;
}
private function buildMetaHTML(): string
{
$date = get_the_date();
$author = get_the_author();
return sprintf(
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
esc_html($date),
esc_html($author)
);
}
private function buildExcerptHTML(int $length): string
{
$excerpt = get_the_excerpt();
if (empty($excerpt)) {
$excerpt = wp_trim_words(get_the_content(), $length, '...');
} else {
$excerpt = wp_trim_words($excerpt, $length, '...');
}
return sprintf(
'<p class="card-text">%s</p>',
esc_html($excerpt)
);
}
private function buildPaginationHTML(): string
{
global $wp_query;
$totalPages = $wp_query->max_num_pages;
if ($totalPages <= 1) {
return '';
}
$currentPage = max(1, get_query_var('paged', 1));
$html = '<nav aria-label="Paginacion"><ul class="pagination justify-content-center">';
// Boton Inicio (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Inicio</a></li>',
esc_url(get_pagenum_link(1))
);
// Numeros de pagina - mostrar 5 paginas
$visiblePages = 5;
$start = max(1, $currentPage - 2);
$end = min($totalPages, $start + $visiblePages - 1);
// Ajustar inicio si estamos cerca del final
if ($end - $start < $visiblePages - 1) {
$start = max(1, $end - $visiblePages + 1);
}
for ($i = $start; $i <= $end; $i++) {
if ($i === $currentPage) {
$html .= sprintf(
'<li class="page-item active"><span class="page-link">%d</span></li>',
$i
);
} else {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">%d</a></li>',
esc_url(get_pagenum_link($i)),
$i
);
}
}
// Ver mas (siguiente pagina)
if ($currentPage < $totalPages) {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Ver mas</a></li>',
esc_url(get_pagenum_link($currentPage + 1))
);
}
// Boton Fin (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Fin</a></li>',
esc_url(get_pagenum_link($totalPages))
);
$html .= '</ul></nav>';
return $html;
}
}

View File

@@ -111,8 +111,23 @@ final class TableOfContentsRenderer implements RendererInterface
return [];
}
// Intentar primero con contenido filtrado (respeta shortcodes, etc.)
$content = apply_filters('the_content', $post->post_content);
// Verificar si el contenido filtrado tiene headings
$hasFilteredHeadings = preg_match('/<h[2-6][^>]*>/i', $content);
// FIX: Si el contenido filtrado no tiene headings pero el raw si,
// usar el contenido raw. Esto ocurre cuando plugins como Thrive
// transforman el contenido para usuarios no logueados.
if (!$hasFilteredHeadings) {
$hasRawHeadings = preg_match('/<h[2-6][^>]*>/i', $post->post_content);
if ($hasRawHeadings) {
// Usar wpautop para dar formato basico al contenido raw
$content = wpautop($post->post_content);
}
}
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);

View File

233
Schemas/archive-header.json Normal file
View File

@@ -0,0 +1,233 @@
{
"component_name": "archive-header",
"version": "1.0.0",
"description": "Cabecera dinamica para paginas de archivo con titulo y descripcion contextual",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Activar componente",
"default": true,
"editable": true,
"required": true
},
"show_on_desktop": {
"type": "boolean",
"label": "Mostrar en escritorio",
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas >= 992px"
},
"show_on_mobile": {
"type": "boolean",
"label": "Mostrar en movil",
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
}
}
},
"content": {
"label": "Contenido",
"priority": 20,
"fields": {
"blog_title": {
"type": "text",
"label": "Titulo del blog",
"default": "Blog",
"editable": true,
"description": "Titulo mostrado en la pagina principal del blog"
},
"show_post_count": {
"type": "boolean",
"label": "Mostrar contador de posts",
"default": true,
"editable": true,
"description": "Muestra el numero de posts encontrados"
},
"show_description": {
"type": "boolean",
"label": "Mostrar descripcion",
"default": true,
"editable": true,
"description": "Muestra la descripcion de categoria/tag si existe"
},
"category_prefix": {
"type": "text",
"label": "Prefijo categoria",
"default": "Categoria:",
"editable": true
},
"tag_prefix": {
"type": "text",
"label": "Prefijo etiqueta",
"default": "Etiqueta:",
"editable": true
},
"author_prefix": {
"type": "text",
"label": "Prefijo autor",
"default": "Articulos de:",
"editable": true
},
"date_prefix": {
"type": "text",
"label": "Prefijo fecha",
"default": "Archivo:",
"editable": true
},
"search_prefix": {
"type": "text",
"label": "Prefijo busqueda",
"default": "Resultados para:",
"editable": true
},
"posts_count_singular": {
"type": "text",
"label": "Texto singular posts",
"default": "publicacion",
"editable": true
},
"posts_count_plural": {
"type": "text",
"label": "Texto plural posts",
"default": "publicaciones",
"editable": true
}
}
},
"typography": {
"label": "Tipografia",
"priority": 30,
"fields": {
"heading_level": {
"type": "select",
"label": "Nivel de encabezado",
"default": "h1",
"editable": true,
"options": ["h1", "h2", "h3", "h4", "h5", "h6"],
"description": "Nivel semantico del titulo para SEO"
},
"title_size": {
"type": "text",
"label": "Tamano titulo",
"default": "2rem",
"editable": true
},
"title_weight": {
"type": "text",
"label": "Peso titulo",
"default": "700",
"editable": true
},
"description_size": {
"type": "text",
"label": "Tamano descripcion",
"default": "1rem",
"editable": true
},
"count_size": {
"type": "text",
"label": "Tamano contador",
"default": "0.875rem",
"editable": true
}
}
},
"colors": {
"label": "Colores",
"priority": 40,
"fields": {
"title_color": {
"type": "color",
"label": "Color titulo",
"default": "#0E2337",
"editable": true
},
"description_color": {
"type": "color",
"label": "Color descripcion",
"default": "#6b7280",
"editable": true
},
"count_bg_color": {
"type": "color",
"label": "Fondo contador",
"default": "#FF8600",
"editable": true
},
"count_text_color": {
"type": "color",
"label": "Texto contador",
"default": "#ffffff",
"editable": true
},
"prefix_color": {
"type": "color",
"label": "Color prefijo",
"default": "#6b7280",
"editable": true
}
}
},
"spacing": {
"label": "Espaciado",
"priority": 50,
"fields": {
"margin_top": {
"type": "text",
"label": "Margen superior",
"default": "2rem",
"editable": true
},
"margin_bottom": {
"type": "text",
"label": "Margen inferior",
"default": "2rem",
"editable": true
},
"padding": {
"type": "text",
"label": "Padding interno",
"default": "1.5rem",
"editable": true
},
"title_margin_bottom": {
"type": "text",
"label": "Margen inferior titulo",
"default": "0.5rem",
"editable": true
},
"count_padding": {
"type": "text",
"label": "Padding contador",
"default": "0.25rem 0.75rem",
"editable": true
}
}
},
"behavior": {
"label": "Comportamiento",
"priority": 70,
"fields": {
"is_sticky": {
"type": "boolean",
"label": "Header fijo",
"default": false,
"editable": true,
"description": "Mantiene el header visible al hacer scroll"
},
"sticky_offset": {
"type": "text",
"label": "Offset sticky",
"default": "0",
"editable": true,
"description": "Distancia desde el top cuando es sticky"
}
}
}
}
}

271
Schemas/post-grid.json Normal file
View File

@@ -0,0 +1,271 @@
{
"component_name": "post-grid",
"version": "1.0.0",
"description": "Grid de posts para templates de listados usando el loop principal de WordPress",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"default": true,
"label": "Habilitar componente"
},
"show_on_desktop": {
"type": "boolean",
"default": true,
"label": "Mostrar en desktop"
},
"show_on_mobile": {
"type": "boolean",
"default": true,
"label": "Mostrar en movil"
}
}
},
"content": {
"label": "Contenido",
"priority": 20,
"fields": {
"show_thumbnail": {
"type": "boolean",
"default": true,
"label": "Mostrar imagen destacada"
},
"show_excerpt": {
"type": "boolean",
"default": true,
"label": "Mostrar extracto"
},
"show_meta": {
"type": "boolean",
"default": true,
"label": "Mostrar metadatos (fecha, autor)"
},
"show_categories": {
"type": "boolean",
"default": true,
"label": "Mostrar categorias"
},
"excerpt_length": {
"type": "select",
"default": "20",
"label": "Longitud del extracto (palabras)",
"options": ["10", "15", "20", "25", "30"]
},
"read_more_text": {
"type": "text",
"default": "Leer mas",
"label": "Texto de leer mas"
},
"no_posts_message": {
"type": "text",
"default": "No se encontraron publicaciones",
"label": "Mensaje cuando no hay posts"
}
}
},
"typography": {
"label": "Tipografia",
"priority": 30,
"fields": {
"heading_level": {
"type": "select",
"default": "h3",
"label": "Nivel de encabezado de tarjetas",
"options": ["h2", "h3", "h4", "h5", "h6"]
},
"card_title_size": {
"type": "text",
"default": "1.1rem",
"label": "Tamano titulo de tarjeta"
},
"card_title_weight": {
"type": "text",
"default": "600",
"label": "Peso titulo de tarjeta"
},
"excerpt_size": {
"type": "text",
"default": "0.9rem",
"label": "Tamano de extracto"
},
"meta_size": {
"type": "text",
"default": "0.8rem",
"label": "Tamano de metadatos"
}
}
},
"colors": {
"label": "Colores",
"priority": 40,
"fields": {
"card_bg_color": {
"type": "color",
"default": "#ffffff",
"label": "Fondo de tarjeta"
},
"card_title_color": {
"type": "color",
"default": "#0E2337",
"label": "Color titulo de tarjeta"
},
"card_hover_bg_color": {
"type": "color",
"default": "#f9fafb",
"label": "Fondo hover de tarjeta"
},
"card_border_color": {
"type": "color",
"default": "#e5e7eb",
"label": "Color borde de tarjeta"
},
"card_hover_border_color": {
"type": "color",
"default": "#FF8600",
"label": "Color borde hover"
},
"excerpt_color": {
"type": "color",
"default": "#6b7280",
"label": "Color de extracto"
},
"meta_color": {
"type": "color",
"default": "#9ca3af",
"label": "Color de metadatos"
},
"category_bg_color": {
"type": "color",
"default": "#FFF5EB",
"label": "Fondo de categoria"
},
"category_text_color": {
"type": "color",
"default": "#FF8600",
"label": "Color texto categoria"
},
"pagination_color": {
"type": "color",
"default": "#0E2337",
"label": "Color de paginacion"
},
"pagination_active_bg": {
"type": "color",
"default": "#FF8600",
"label": "Fondo paginacion activa"
},
"pagination_active_color": {
"type": "color",
"default": "#ffffff",
"label": "Color texto paginacion activa"
}
}
},
"spacing": {
"label": "Espaciado",
"priority": 50,
"fields": {
"grid_gap": {
"type": "text",
"default": "1.5rem",
"label": "Espacio entre tarjetas"
},
"card_padding": {
"type": "text",
"default": "1.25rem",
"label": "Padding interno de tarjeta"
},
"section_margin_top": {
"type": "text",
"default": "0",
"label": "Margen superior de seccion"
},
"section_margin_bottom": {
"type": "text",
"default": "2rem",
"label": "Margen inferior de seccion"
}
}
},
"visual_effects": {
"label": "Efectos Visuales",
"priority": 60,
"fields": {
"card_border_radius": {
"type": "text",
"default": "0.5rem",
"label": "Radio de borde de tarjeta"
},
"card_shadow": {
"type": "text",
"default": "0 1px 3px rgba(0,0,0,0.1)",
"label": "Sombra de tarjeta"
},
"card_hover_shadow": {
"type": "text",
"default": "0 4px 12px rgba(0,0,0,0.15)",
"label": "Sombra hover de tarjeta"
},
"card_transition": {
"type": "text",
"default": "all 0.3s ease",
"label": "Transicion de tarjeta"
},
"image_border_radius": {
"type": "text",
"default": "0.375rem",
"label": "Radio de borde de imagen"
}
}
},
"layout": {
"label": "Disposicion",
"priority": 80,
"fields": {
"columns_desktop": {
"type": "select",
"default": "3",
"label": "Columnas en desktop",
"options": ["2", "3", "4"]
},
"columns_tablet": {
"type": "select",
"default": "2",
"label": "Columnas en tablet",
"options": ["1", "2", "3"]
},
"columns_mobile": {
"type": "select",
"default": "1",
"label": "Columnas en movil",
"options": ["1", "2"]
},
"image_position": {
"type": "select",
"default": "top",
"label": "Posicion de imagen",
"options": ["top", "left", "none"]
}
}
},
"media": {
"label": "Medios",
"priority": 90,
"fields": {
"fallback_image": {
"type": "url",
"default": "",
"label": "Imagen por defecto (URL)"
},
"fallback_image_alt": {
"type": "text",
"default": "Imagen por defecto",
"label": "Texto alternativo imagen por defecto"
}
}
}
}
}

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -22,15 +22,15 @@ final class EvaluatePageVisibilityUseCase
) {}
/**
* Evalúa si el componente debe mostrarse en la página actual
* Evalua si el componente debe mostrarse en la pagina actual
*/
public function execute(string $componentName): bool
{
$config = $this->visibilityRepository->getVisibilityConfig($componentName);
if (empty($config)) {
// Usar constante compartida (DRY)
$config = VisibilityDefaults::DEFAULT_VISIBILITY;
// Usar defaults especificos por componente si existen
$config = VisibilityDefaults::getForComponent($componentName);
}
$pageType = $this->pageTypeDetector->detect();

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
/**
* RenderPostGridRequest - DTO de entrada para renderizar post grid shortcode
*
* RESPONSABILIDAD: Encapsular todos los atributos del shortcode [roi_post_grid]
*
* USO:
* ```php
* $request = RenderPostGridRequest::fromArray([
* 'category' => 'precios-unitarios',
* 'posts_per_page' => 6,
* 'columns' => 3
* ]);
* ```
*
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
*/
final readonly class RenderPostGridRequest
{
public function __construct(
public string $category = '',
public string $excludeCategory = '',
public string $tag = '',
public string $author = '',
public int $postsPerPage = 9,
public int $columns = 3,
public string $orderby = 'date',
public string $order = 'DESC',
public bool $showPagination = false,
public int $offset = 0,
public string $excludePosts = '',
public bool $showThumbnail = true,
public bool $showExcerpt = true,
public bool $showMeta = true,
public bool $showCategories = true,
public int $excerptLength = 20,
public string $class = '',
public string $id = '',
public int $paged = 1
) {}
/**
* Factory method: Crear desde array de atributos del shortcode
*
* @param array<string, mixed> $atts Atributos sanitizados del shortcode
* @return self
*/
public static function fromArray(array $atts): self
{
return new self(
category: (string) ($atts['category'] ?? ''),
excludeCategory: (string) ($atts['exclude_category'] ?? ''),
tag: (string) ($atts['tag'] ?? ''),
author: (string) ($atts['author'] ?? ''),
postsPerPage: self::clampInt((int) ($atts['posts_per_page'] ?? 9), 1, 50),
columns: self::clampInt((int) ($atts['columns'] ?? 3), 1, 4),
orderby: self::sanitizeOrderby((string) ($atts['orderby'] ?? 'date')),
order: self::sanitizeOrder((string) ($atts['order'] ?? 'DESC')),
showPagination: self::toBool($atts['show_pagination'] ?? false),
offset: max(0, (int) ($atts['offset'] ?? 0)),
excludePosts: (string) ($atts['exclude_posts'] ?? ''),
showThumbnail: self::toBool($atts['show_thumbnail'] ?? true),
showExcerpt: self::toBool($atts['show_excerpt'] ?? true),
showMeta: self::toBool($atts['show_meta'] ?? true),
showCategories: self::toBool($atts['show_categories'] ?? true),
excerptLength: max(1, (int) ($atts['excerpt_length'] ?? 20)),
class: (string) ($atts['class'] ?? ''),
id: (string) ($atts['id'] ?? ''),
paged: max(1, (int) ($atts['paged'] ?? 1))
);
}
/**
* Convertir a array de parametros para QueryBuilder
*
* @return array<string, mixed>
*/
public function toQueryParams(): array
{
return [
'category' => $this->category,
'exclude_category' => $this->excludeCategory,
'tag' => $this->tag,
'author' => $this->author,
'posts_per_page' => $this->postsPerPage,
'orderby' => $this->orderby,
'order' => $this->order,
'offset' => $this->offset,
'exclude_posts' => $this->excludePosts,
'paged' => $this->paged,
];
}
/**
* Convertir a array de opciones para Renderer
*
* @return array<string, mixed>
*/
public function toRenderOptions(): array
{
return [
'columns' => $this->columns,
'show_thumbnail' => $this->showThumbnail,
'show_excerpt' => $this->showExcerpt,
'show_meta' => $this->showMeta,
'show_categories' => $this->showCategories,
'excerpt_length' => $this->excerptLength,
'class' => $this->class,
'id' => $this->id,
'show_pagination' => $this->showPagination,
'posts_per_page' => $this->postsPerPage,
];
}
private static function clampInt(int $value, int $min, int $max): int
{
return max($min, min($max, $value));
}
private static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return $value === 'true' || $value === '1' || $value === 1;
}
private static function sanitizeOrderby(string $value): string
{
$allowed = ['date', 'title', 'modified', 'rand', 'comment_count'];
return in_array($value, $allowed, true) ? $value : 'date';
}
private static function sanitizeOrder(string $value): string
{
$upper = strtoupper($value);
return in_array($upper, ['ASC', 'DESC'], true) ? $upper : 'DESC';
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
/**
* Caso de uso: Renderizar grid de posts para shortcode
*
* RESPONSABILIDAD: Orquestar la obtencion de settings, construccion de query
* y renderizado del grid. No contiene logica de negocio, solo coordinacion.
*
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
*/
final class RenderPostGridUseCase
{
private const COMPONENT_NAME = 'post-grid';
public function __construct(
private readonly PostGridQueryBuilderInterface $queryBuilder,
private readonly PostGridShortcodeRendererInterface $renderer,
private readonly ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Ejecuta el caso de uso: obtiene settings, construye query y renderiza
*
* @param RenderPostGridRequest $request DTO con atributos del shortcode
* @return string HTML del grid renderizado
*/
public function execute(RenderPostGridRequest $request): string
{
// 1. Obtener settings del componente post-grid desde BD
$settings = $this->getSettings();
// 2. Construir query con los parametros del shortcode
$query = $this->queryBuilder->build($request->toQueryParams());
// 3. Renderizar grid con query, settings y opciones
$html = $this->renderer->render(
$query,
$settings,
$request->toRenderOptions()
);
// 4. Limpiar query (importante para evitar conflictos)
wp_reset_postdata();
return $html;
}
/**
* Obtiene settings del componente post-grid
*
* @return array<string, mixed>
*/
private function getSettings(): array
{
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
return VisibilityDefaults::getForComponent(self::COMPONENT_NAME);
}
return $settings;
}
}

View File

@@ -16,13 +16,13 @@ namespace ROITheme\Shared\Domain\Constants;
final class VisibilityDefaults
{
/**
* Configuración de visibilidad por defecto para nuevos componentes
* Configuracion de visibilidad por defecto para componentes generales
*
* - 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)
* - Home: SI mostrar (pagina principal)
* - Posts: SI mostrar (articulos del blog)
* - Pages: SI mostrar (paginas estaticas)
* - Archives: NO mostrar (listados de categorias/tags)
* - Search: NO mostrar (resultados de busqueda)
*/
public const DEFAULT_VISIBILITY = [
'show_on_home' => true,
@@ -33,7 +33,39 @@ final class VisibilityDefaults
];
/**
* Lista de campos de visibilidad válidos
* Defaults especificos por componente (sobrescriben DEFAULT_VISIBILITY)
*
* Componentes de listados:
* - archive-header: Solo en archives (home para blog title)
* - post-grid: En home, archives y search
* - cta-box-sidebar: Tambien en archives
*/
public const COMPONENT_VISIBILITY = [
'archive-header' => [
'show_on_home' => true, // Para mostrar blog_title
'show_on_posts' => false,
'show_on_pages' => false,
'show_on_archives' => true, // Proposito principal
'show_on_search' => true, // Mostrar "Resultados: X"
],
'post-grid' => [
'show_on_home' => true, // Blog principal
'show_on_posts' => false,
'show_on_pages' => false,
'show_on_archives' => true, // Listados de categoria/tag
'show_on_search' => true, // Resultados de busqueda
],
'cta-box-sidebar' => [
'show_on_home' => true,
'show_on_posts' => true,
'show_on_pages' => true,
'show_on_archives' => true, // Visible en archives
'show_on_search' => false,
],
];
/**
* Lista de campos de visibilidad validos
*/
public const VISIBILITY_FIELDS = [
'show_on_home',
@@ -42,4 +74,15 @@ final class VisibilityDefaults
'show_on_archives',
'show_on_search',
];
/**
* Obtiene los defaults para un componente especifico
*
* @param string $componentName Nombre del componente (kebab-case)
* @return array<string, bool> Configuracion de visibilidad
*/
public static function getForComponent(string $componentName): array
{
return self::COMPONENT_VISIBILITY[$componentName] ?? self::DEFAULT_VISIBILITY;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface PostGridQueryBuilderInterface
*
* Contrato para construccion de queries de posts para el shortcode post-grid.
* Define el comportamiento esperado para construir WP_Query sin depender
* de implementaciones especificas.
*
* Responsabilidades:
* - Construir WP_Query a partir de parametros de filtro
* - Aplicar filtros por categoria, tag, autor
* - Configurar paginacion y ordenamiento
*
* NO responsable de:
* - Generar HTML
* - Generar CSS
* - Obtener settings de BD
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PostGridQueryBuilderInterface
{
/**
* Construye un WP_Query configurado con los parametros proporcionados.
*
* Ejemplo:
* ```php
* $params = [
* 'category' => 'precios-unitarios',
* 'tag' => 'concreto',
* 'posts_per_page' => 6,
* 'orderby' => 'date',
* 'order' => 'DESC'
* ];
*
* $query = $builder->build($params);
* ```
*
* @param array<string, mixed> $params Parametros de filtro y configuracion
* Keys soportadas: category, exclude_category, tag,
* author, posts_per_page, orderby, order, offset,
* exclude_posts, paged
*
* @return \WP_Query Query configurado listo para iterar
*/
public function build(array $params): \WP_Query;
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface PostGridShortcodeRendererInterface
*
* Contrato para renderizado HTML del shortcode post-grid.
* Define el comportamiento esperado para generar el HTML del grid
* sin depender de implementaciones especificas.
*
* Responsabilidades:
* - Generar HTML del grid de posts
* - Generar CSS inline usando CSSGeneratorInterface
* - Aplicar clases responsive de Bootstrap
*
* NO responsable de:
* - Construir queries
* - Obtener settings de BD
* - Sanitizar atributos del shortcode
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PostGridShortcodeRendererInterface
{
/**
* Renderiza el grid de posts como HTML.
*
* Ejemplo:
* ```php
* $html = $renderer->render($query, $settings, [
* 'columns' => 3,
* 'show_thumbnail' => true,
* 'show_excerpt' => true,
* 'id' => 'grid-cursos'
* ]);
* ```
*
* @param \WP_Query $query Query con los posts a mostrar
* @param array<string, mixed> $settings Settings del componente post-grid desde BD
* @param array<string, mixed> $options Opciones de visualizacion del shortcode
* Keys: columns, show_thumbnail, show_excerpt,
* show_meta, show_categories, excerpt_length,
* class, id, show_pagination
*
* @return string HTML completo del grid incluyendo CSS inline
*/
public function render(\WP_Query $query, array $settings, array $options): string;
}

View File

@@ -42,6 +42,12 @@ use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentVisibilityRepository;
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
use ROITheme\Shared\Infrastructure\Wordpress\BodyClassHooksRegistrar;
// Post Grid Shortcode (Plan post-grid-shortcode)
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Infrastructure\Query\PostGridQueryBuilder;
use ROITheme\Shared\Infrastructure\Ui\PostGridShortcodeRenderer;
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridUseCase;
/**
* DIContainer - Contenedor de Inyección de Dependencias
@@ -493,4 +499,47 @@ final class DIContainer
}
return $this->instances['bodyClassHooksRegistrar'];
}
// ===============================
// Post Grid Shortcode System
// ===============================
/**
* Obtiene el query builder para post grid shortcode
*/
public function getPostGridQueryBuilder(): PostGridQueryBuilderInterface
{
if (!isset($this->instances['postGridQueryBuilder'])) {
$this->instances['postGridQueryBuilder'] = new PostGridQueryBuilder();
}
return $this->instances['postGridQueryBuilder'];
}
/**
* Obtiene el renderer para post grid shortcode
*/
public function getPostGridShortcodeRenderer(): PostGridShortcodeRendererInterface
{
if (!isset($this->instances['postGridShortcodeRenderer'])) {
$this->instances['postGridShortcodeRenderer'] = new PostGridShortcodeRenderer(
$this->getCSSGeneratorService()
);
}
return $this->instances['postGridShortcodeRenderer'];
}
/**
* Obtiene el caso de uso para renderizar post grid shortcode
*/
public function getRenderPostGridUseCase(): RenderPostGridUseCase
{
if (!isset($this->instances['renderPostGridUseCase'])) {
$this->instances['renderPostGridUseCase'] = new RenderPostGridUseCase(
$this->getPostGridQueryBuilder(),
$this->getPostGridShortcodeRenderer(),
$this->getComponentSettingsRepository()
);
}
return $this->instances['renderPostGridUseCase'];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Hooks;
/**
* Registra hooks para arquitectura cache-first.
*
* Permite que plugins externos evalúen condiciones ANTES de servir páginas,
* sin bloquear el cache de WordPress.
*
* @see openspec/specs/cache-first-architecture/spec.md
* @package ROITheme\Shared\Infrastructure\Hooks
*/
final class CacheFirstHooksRegistrar
{
/**
* Registra los hooks de cache-first.
*/
public function register(): void
{
add_action('template_redirect', [$this, 'fireBeforePageServe'], 0);
}
/**
* Dispara hook para que plugins externos evalúen acceso.
*
* Solo se dispara para:
* - Páginas singulares (posts, pages, CPTs)
* - Visitantes NO logueados (cache no aplica a usuarios logueados)
*
* Los plugins pueden llamar wp_safe_redirect() + exit para bloquear.
* Si no hacen nada, la página se sirve normalmente (con cache si disponible).
*/
public function fireBeforePageServe(): void
{
// No para usuarios logueados (cache no aplica, no tiene sentido evaluar)
if (is_user_logged_in()) {
return;
}
// Solo páginas singulares
if (!is_singular()) {
return;
}
// No en admin/ajax/cron/REST
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
$post_id = get_queried_object_id();
if ($post_id > 0) {
/**
* Hook: roi_theme_before_page_serve
*
* Permite que plugins externos evalúen condiciones antes de servir página.
*
* Uso típico:
* - Rate limiters (límite de vistas por IP)
* - Membership plugins (verificar acceso)
* - Geolocation restrictions
*
* Para bloquear acceso:
* wp_safe_redirect('/pagina-destino/', 302);
* exit;
*
* Para permitir acceso:
* return; // La página se servirá (con cache si disponible)
*
* @since 1.0.0
* @param int $post_id ID del post/page que se va a servir
*/
do_action('roi_theme_before_page_serve', $post_id);
}
}
}

View File

@@ -61,15 +61,35 @@ final class WordPressComponentVisibilityRepository implements WrapperVisibilityC
/**
* {@inheritDoc}
*
* Delega a PageVisibilityHelper que ya implementa:
* Evalúa múltiples criterios de exclusión:
* - hide_for_logged_in: Ocultar para usuarios logueados
* - Visibilidad por tipo de página (home, posts, pages, archives, search)
* - Exclusiones por categoría, post ID, URL pattern
*/
public function isNotExcluded(string $componentName): bool
{
// Verificar hide_for_logged_in
if ($this->shouldHideForLoggedIn($componentName)) {
return false;
}
return PageVisibilityHelper::shouldShow($componentName);
}
/**
* Verifica si debe ocultarse para usuarios logueados
*/
private function shouldHideForLoggedIn(string $componentName): bool
{
$value = $this->getVisibilityAttribute($componentName, 'hide_for_logged_in');
if ($value === null) {
return false;
}
return $this->toBool($value) && is_user_logged_in();
}
/**
* Obtiene un atributo del grupo visibility desde la BD
*

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Query;
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
/**
* Implementacion de PostGridQueryBuilderInterface
*
* RESPONSABILIDAD: Construir WP_Query a partir de parametros de filtro.
* No genera HTML ni obtiene settings.
*
* @package ROITheme\Shared\Infrastructure\Query
*/
final class PostGridQueryBuilder implements PostGridQueryBuilderInterface
{
/**
* {@inheritdoc}
*/
public function build(array $params): \WP_Query
{
$args = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => (int) ($params['posts_per_page'] ?? 9),
'orderby' => $params['orderby'] ?? 'date',
'order' => $params['order'] ?? 'DESC',
'paged' => (int) ($params['paged'] ?? 1),
];
// Offset
if (!empty($params['offset'])) {
$args['offset'] = (int) $params['offset'];
}
// Filtro por categoria(s)
if (!empty($params['category'])) {
$args['category_name'] = $this->sanitizeSlugs($params['category']);
}
// Excluir categoria(s)
if (!empty($params['exclude_category'])) {
$excludeIds = $this->getCategoryIds($params['exclude_category']);
if (!empty($excludeIds)) {
$args['category__not_in'] = $excludeIds;
}
}
// Filtro por tag(s)
if (!empty($params['tag'])) {
$args['tag'] = $this->sanitizeSlugs($params['tag']);
}
// Filtro por autor
if (!empty($params['author'])) {
$author = $params['author'];
if (is_numeric($author)) {
$args['author'] = (int) $author;
} else {
$user = get_user_by('login', $author);
if ($user) {
$args['author'] = $user->ID;
}
}
}
// Excluir posts por ID
if (!empty($params['exclude_posts'])) {
$excludeIds = array_map('intval', explode(',', $params['exclude_posts']));
$excludeIds = array_filter($excludeIds, fn($id) => $id > 0);
if (!empty($excludeIds)) {
$args['post__not_in'] = $excludeIds;
}
}
return new \WP_Query($args);
}
/**
* Sanitiza slugs separados por coma
*/
private function sanitizeSlugs(string $slugs): string
{
$parts = explode(',', $slugs);
$sanitized = array_map('sanitize_title', $parts);
return implode(',', $sanitized);
}
/**
* Obtiene IDs de categorias desde slugs
*
* @return int[]
*/
private function getCategoryIds(string $slugs): array
{
$parts = explode(',', $slugs);
$ids = [];
foreach ($parts as $slug) {
$term = get_term_by('slug', trim($slug), 'category');
if ($term instanceof \WP_Term) {
$ids[] = $term->term_id;
}
}
return $ids;
}
}

View File

@@ -1,94 +0,0 @@
<?php
/**
* Busca casos variados de problemas de listas para validación exhaustiva
*/
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
$conn->set_charset("utf8mb4");
function detectIssues($html) {
$issues = [];
libxml_use_internal_errors(true);
$doc = new DOMDocument("1.0", "UTF-8");
$wrapped = '<div id="wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$validChildren = ["li", "script", "template"];
foreach (["ul", "ol"] as $tag) {
foreach ($doc->getElementsByTagName($tag) as $list) {
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$childTag = strtolower($child->nodeName);
if (!in_array($childTag, $validChildren)) {
$issues[] = ["parent" => $tag, "child" => $childTag];
}
}
}
}
}
return $issues;
}
echo "BUSCANDO CASOS VARIADOS...\n\n";
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE html IS NOT NULL AND html != '' ORDER BY id";
$result = $conn->query($query);
if (!$result) {
die("Error en query: " . $conn->error);
}
$cases = [
"many_issues" => [],
"ol_issues" => [],
"mixed_issues" => [],
"few_issues" => []
];
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row["html"]);
if (empty($issues)) continue;
$count = count($issues);
$hasOl = false;
$hasUl = false;
foreach ($issues as $issue) {
if ($issue["parent"] === "ol") $hasOl = true;
if ($issue["parent"] === "ul") $hasUl = true;
}
if ($count > 10 && count($cases["many_issues"]) < 3) {
$cases["many_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
}
if ($hasOl && !$hasUl && count($cases["ol_issues"]) < 3) {
$cases["ol_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
}
if ($hasOl && $hasUl && count($cases["mixed_issues"]) < 3) {
$cases["mixed_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
}
if ($count <= 2 && count($cases["few_issues"]) < 3) {
$cases["few_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
}
}
foreach ($cases as $type => $posts) {
echo "=== " . strtoupper($type) . " ===\n";
if (empty($posts)) {
echo " (ninguno encontrado)\n\n";
continue;
}
foreach ($posts as $post) {
echo "ID: {$post["id"]} - {$post["count"]} problemas\n";
echo "URL: {$post["url"]}\n";
echo "Tipos: ";
$types = [];
foreach ($post["issues"] as $i) {
$types[] = "<{$i["parent"]}> contiene <{$i["child"]}>";
}
echo implode(", ", array_unique($types)) . "\n\n";
}
}
$conn->close();

View File

@@ -1,411 +0,0 @@
<?php
/**
* Corrector de Listas HTML Mal Formadas usando DOMDocument
*
* PROPÓSITO: Detectar y corregir listas con estructura inválida
* - <ul>/<ol> conteniendo elementos no-<li> como hijos directos
* - Listas anidadas que son hermanas en lugar de hijas de <li>
*
* USO:
* php fix-malformed-lists-dom.php --mode=scan # Solo escanear
* php fix-malformed-lists-dom.php --mode=test # Probar corrección (1 post)
* php fix-malformed-lists-dom.php --mode=fix # Aplicar correcciones
*
* @package ROI_Theme
* @since Phase 4.4 Accessibility
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('memory_limit', '512M');
set_time_limit(600);
// Configuración
$db_config = [
'host' => 'localhost',
'database' => 'preciosunitarios_seo',
'username' => 'preciosunitarios_seo',
'password' => 'ACl%EEFd=V-Yvb??',
'charset' => 'utf8mb4'
];
// Parsear argumentos
$mode = 'scan';
foreach ($argv as $arg) {
if (strpos($arg, '--mode=') === 0) {
$mode = substr($arg, 7);
}
}
echo "==============================================\n";
echo " CORRECTOR DE LISTAS - DOMDocument\n";
echo " Modo: $mode\n";
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
echo "==============================================\n\n";
/**
* Conectar a la base de datos
*/
function connectDatabase(array $config): ?mysqli {
$conn = new mysqli(
$config['host'],
$config['username'],
$config['password'],
$config['database']
);
if ($conn->connect_error) {
echo "Error de conexión: " . $conn->connect_error . "\n";
return null;
}
$conn->set_charset($config['charset']);
return $conn;
}
/**
* Corregir listas mal formadas usando DOMDocument
*/
function fixMalformedLists(string $html): array {
$result = [
'fixed' => false,
'html' => $html,
'changes' => 0,
'details' => []
];
// Suprimir errores de HTML mal formado
libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
// Envolver en contenedor para preservar estructura
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
// Procesar todas las listas (ul y ol)
$lists = [];
foreach ($doc->getElementsByTagName('ul') as $ul) {
$lists[] = $ul;
}
foreach ($doc->getElementsByTagName('ol') as $ol) {
$lists[] = $ol;
}
$changes = 0;
foreach ($lists as $list) {
$changes += fixListChildren($list, $result['details']);
}
if ($changes > 0) {
// Extraer HTML corregido
$wrapper = $doc->getElementById('temp-wrapper');
if ($wrapper) {
$innerHTML = '';
foreach ($wrapper->childNodes as $child) {
$innerHTML .= $doc->saveHTML($child);
}
$result['html'] = $innerHTML;
$result['fixed'] = true;
$result['changes'] = $changes;
}
}
return $result;
}
/**
* Corregir hijos de una lista (solo debe contener li, script, template)
*/
function fixListChildren(DOMElement $list, array &$details): int {
$changes = 0;
$validChildren = ['li', 'script', 'template'];
$nodesToProcess = [];
// Recopilar nodos que necesitan corrección
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$nodesToProcess[] = $child;
}
}
}
// Procesar cada nodo inválido
foreach ($nodesToProcess as $node) {
$tagName = strtolower($node->nodeName);
// Si es una lista anidada (ul/ol), envolverla en <li>
if ($tagName === 'ul' || $tagName === 'ol') {
$changes += wrapInLi($list, $node, $details);
}
// Otros elementos inválidos también se envuelven en <li>
else {
$changes += wrapInLi($list, $node, $details);
}
}
return $changes;
}
/**
* Envolver un nodo en <li> o moverlo al <li> anterior
*/
function wrapInLi(DOMElement $list, DOMNode $node, array &$details): int {
$doc = $list->ownerDocument;
$tagName = strtolower($node->nodeName);
// Buscar el <li> hermano anterior
$prevLi = null;
$prev = $node->previousSibling;
while ($prev) {
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
$prevLi = $prev;
break;
}
$prev = $prev->previousSibling;
}
if ($prevLi) {
// Mover el nodo al final del <li> anterior
$prevLi->appendChild($node);
$details[] = "Movido <$tagName> dentro del <li> anterior";
return 1;
} else {
// No hay <li> anterior, crear uno nuevo
$newLi = $doc->createElement('li');
$list->insertBefore($newLi, $node);
$newLi->appendChild($node);
$details[] = "Envuelto <$tagName> en nuevo <li>";
return 1;
}
}
/**
* Detectar problemas en HTML sin corregir
*/
function detectIssues(string $html): array {
$issues = [];
libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$validChildren = ['li', 'script', 'template'];
// Revisar ul
foreach ($doc->getElementsByTagName('ul') as $ul) {
foreach ($ul->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$issues[] = [
'list_type' => 'ul',
'invalid_child' => $tagName,
'context' => getNodeContext($child)
];
}
}
}
}
// Revisar ol
foreach ($doc->getElementsByTagName('ol') as $ol) {
foreach ($ol->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$issues[] = [
'list_type' => 'ol',
'invalid_child' => $tagName,
'context' => getNodeContext($child)
];
}
}
}
}
return $issues;
}
/**
* Obtener contexto de un nodo para debug
*/
function getNodeContext(DOMNode $node): string {
$doc = $node->ownerDocument;
$html = $doc->saveHTML($node);
return substr($html, 0, 100) . (strlen($html) > 100 ? '...' : '');
}
// ============================================
// EJECUCIÓN PRINCIPAL
// ============================================
$conn = connectDatabase($db_config);
if (!$conn) {
exit(1);
}
echo "✓ Conexión establecida\n\n";
// Contar registros
$result = $conn->query("SELECT COUNT(*) as total FROM datos_seo_pagina WHERE html IS NOT NULL AND html != ''");
$total = $result->fetch_assoc()['total'];
echo "Total de registros: $total\n\n";
if ($mode === 'scan') {
// MODO SCAN: Solo detectar problemas
echo "MODO: ESCANEO (solo detección)\n";
echo "─────────────────────────────────\n\n";
$batch_size = 100;
$offset = 0;
$affected = 0;
$total_issues = 0;
while ($offset < $total) {
$query = "SELECT id, page, html FROM datos_seo_pagina
WHERE html IS NOT NULL AND html != ''
ORDER BY id LIMIT $batch_size OFFSET $offset";
$result = $conn->query($query);
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['html']);
if (!empty($issues)) {
$affected++;
$total_issues += count($issues);
if ($affected <= 20) {
echo "[ID: {$row['id']}] " . count($issues) . " problema(s)\n";
echo "URL: {$row['page']}\n";
foreach (array_slice($issues, 0, 2) as $issue) {
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
}
echo "\n";
}
}
}
$offset += $batch_size;
if ($offset % 1000 == 0) {
echo "Procesados: $offset/$total...\n";
}
}
echo "─────────────────────────────────\n";
echo "RESUMEN:\n";
echo " Posts afectados: $affected\n";
echo " Total incidencias: $total_issues\n";
} elseif ($mode === 'test') {
// MODO TEST: Probar corrección en 1 post
echo "MODO: PRUEBA (sin guardar)\n";
echo "─────────────────────────────────\n\n";
// Buscar primer post con problemas
$query = "SELECT id, page, html FROM datos_seo_pagina
WHERE html IS NOT NULL AND html != ''
ORDER BY id LIMIT 100";
$result = $conn->query($query);
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['html']);
if (!empty($issues)) {
echo "POST ID: {$row['id']}\n";
echo "URL: {$row['page']}\n";
echo "Problemas detectados: " . count($issues) . "\n\n";
echo "ANTES (problemas):\n";
foreach (array_slice($issues, 0, 3) as $issue) {
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
echo " Contexto: " . htmlspecialchars(substr($issue['context'], 0, 80)) . "\n";
}
// Aplicar corrección
$fixResult = fixMalformedLists($row['html']);
echo "\nDESPUÉS (corrección):\n";
echo " Cambios realizados: {$fixResult['changes']}\n";
foreach ($fixResult['details'] as $detail) {
echo " - $detail\n";
}
// Verificar que no quedan problemas
$issuesAfter = detectIssues($fixResult['html']);
echo "\nVERIFICACIÓN:\n";
echo " Problemas antes: " . count($issues) . "\n";
echo " Problemas después: " . count($issuesAfter) . "\n";
if (count($issuesAfter) < count($issues)) {
echo " ✓ Reducción de problemas\n";
}
// Mostrar fragmento del HTML corregido
if ($fixResult['fixed']) {
echo "\nMUESTRA HTML CORREGIDO (primeros 500 chars):\n";
echo "─────────────────────────────────\n";
echo htmlspecialchars(substr($fixResult['html'], 0, 500)) . "...\n";
}
break;
}
}
} elseif ($mode === 'fix') {
// MODO FIX: Aplicar correcciones
echo "MODO: CORRECCIÓN (GUARDANDO CAMBIOS)\n";
echo "─────────────────────────────────\n\n";
$batch_size = 50;
$offset = 0;
$fixed_count = 0;
$error_count = 0;
while ($offset < $total) {
$query = "SELECT id, page, html FROM datos_seo_pagina
WHERE html IS NOT NULL AND html != ''
ORDER BY id LIMIT $batch_size OFFSET $offset";
$result = $conn->query($query);
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['html']);
if (!empty($issues)) {
$fixResult = fixMalformedLists($row['html']);
if ($fixResult['fixed']) {
// Guardar HTML corregido
$stmt = $conn->prepare("UPDATE datos_seo_pagina SET html = ? WHERE id = ?");
$stmt->bind_param("si", $fixResult['html'], $row['id']);
if ($stmt->execute()) {
$fixed_count++;
echo "[ID: {$row['id']}] ✓ Corregido ({$fixResult['changes']} cambios)\n";
} else {
$error_count++;
echo "[ID: {$row['id']}] ✗ Error al guardar\n";
}
$stmt->close();
}
}
}
$offset += $batch_size;
if ($offset % 500 == 0) {
echo "Procesados: $offset/$total (corregidos: $fixed_count)\n";
}
}
echo "\n─────────────────────────────────\n";
echo "RESUMEN:\n";
echo " Posts corregidos: $fixed_count\n";
echo " Errores: $error_count\n";
}
$conn->close();
echo "\n✓ Proceso completado.\n";

View File

@@ -1,322 +0,0 @@
<?php
/**
* Corrector de Listas HTML Mal Formadas - WordPress Posts
*
* BASE DE DATOS: preciosunitarios_wp
* TABLA: wp_posts
* CAMPO: post_content
*
* USO:
* php fix-malformed-lists-wp-posts.php --mode=scan
* php fix-malformed-lists-wp-posts.php --mode=test
* php fix-malformed-lists-wp-posts.php --mode=fix
*
* @package ROI_Theme
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('memory_limit', '512M');
set_time_limit(600);
$db_config = [
'host' => 'localhost',
'database' => 'preciosunitarios_wp',
'username' => 'preciosunitarios_wp',
'password' => 'Kq#Gk%yEt+PWpVe&HZ',
'charset' => 'utf8mb4'
];
$mode = 'scan';
foreach ($argv as $arg) {
if (strpos($arg, '--mode=') === 0) {
$mode = substr($arg, 7);
}
}
echo "==============================================\n";
echo " CORRECTOR DE LISTAS - WordPress Posts\n";
echo " Base de datos: {$db_config['database']}\n";
echo " Tabla: wp_posts (post_content)\n";
echo " Modo: $mode\n";
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
echo "==============================================\n\n";
function connectDatabase(array $config): ?mysqli {
$conn = new mysqli($config['host'], $config['username'], $config['password'], $config['database']);
if ($conn->connect_error) {
echo "Error de conexión: " . $conn->connect_error . "\n";
return null;
}
$conn->set_charset($config['charset']);
return $conn;
}
function detectIssues(string $html): array {
$issues = [];
if (empty(trim($html))) return $issues;
libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$validChildren = ['li', 'script', 'template'];
foreach (['ul', 'ol'] as $listTag) {
foreach ($doc->getElementsByTagName($listTag) as $list) {
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$issues[] = [
'list_type' => $listTag,
'invalid_child' => $tagName
];
}
}
}
}
}
return $issues;
}
function fixMalformedLists(string $html): array {
$result = ['fixed' => false, 'html' => $html, 'changes' => 0, 'details' => []];
if (empty(trim($html))) return $result;
libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$lists = [];
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
$changes = 0;
$validChildren = ['li', 'script', 'template'];
foreach ($lists as $list) {
$nodesToProcess = [];
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$nodesToProcess[] = $child;
}
}
}
foreach ($nodesToProcess as $node) {
$tagName = strtolower($node->nodeName);
$prevLi = null;
$prev = $node->previousSibling;
while ($prev) {
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
$prevLi = $prev;
break;
}
$prev = $prev->previousSibling;
}
if ($prevLi) {
$prevLi->appendChild($node);
$result['details'][] = "Movido <$tagName> dentro del <li> anterior";
$changes++;
} else {
$newLi = $doc->createElement('li');
$list->insertBefore($newLi, $node);
$newLi->appendChild($node);
$result['details'][] = "Envuelto <$tagName> en nuevo <li>";
$changes++;
}
}
}
if ($changes > 0) {
$wrapper = $doc->getElementById('temp-wrapper');
if ($wrapper) {
$innerHTML = '';
foreach ($wrapper->childNodes as $child) {
$innerHTML .= $doc->saveHTML($child);
}
$result['html'] = $innerHTML;
$result['fixed'] = true;
$result['changes'] = $changes;
}
}
return $result;
}
// EJECUCIÓN PRINCIPAL
$conn = connectDatabase($db_config);
if (!$conn) {
exit(1);
}
echo "✓ Conexión establecida\n\n";
// Solo posts publicados con contenido
$countQuery = "SELECT COUNT(*) as total FROM wp_posts
WHERE post_status = 'publish'
AND post_type IN ('post', 'page')
AND post_content IS NOT NULL
AND post_content != ''";
$result = $conn->query($countQuery);
$total = $result->fetch_assoc()['total'];
echo "Total de posts/páginas publicados: $total\n\n";
if ($mode === 'scan') {
echo "MODO: ESCANEO (solo detección)\n";
echo "─────────────────────────────────\n\n";
$batch_size = 100;
$offset = 0;
$affected = 0;
$total_issues = 0;
while ($offset < $total) {
$query = "SELECT ID, post_title, post_content, guid FROM wp_posts
WHERE post_status = 'publish'
AND post_type IN ('post', 'page')
AND post_content IS NOT NULL
AND post_content != ''
ORDER BY ID LIMIT $batch_size OFFSET $offset";
$result = $conn->query($query);
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['post_content']);
if (!empty($issues)) {
$affected++;
$total_issues += count($issues);
if ($affected <= 20) {
echo "[ID: {$row['ID']}] " . count($issues) . " problema(s)\n";
echo "Título: " . substr($row['post_title'], 0, 60) . "\n";
foreach (array_slice($issues, 0, 2) as $issue) {
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
}
echo "\n";
}
}
}
$offset += $batch_size;
if ($offset % 1000 == 0) {
echo "Procesados: $offset/$total...\n";
}
}
echo "─────────────────────────────────\n";
echo "RESUMEN:\n";
echo " Posts afectados: $affected\n";
echo " Total incidencias: $total_issues\n";
} elseif ($mode === 'test') {
echo "MODO: PRUEBA (sin guardar)\n";
echo "─────────────────────────────────\n\n";
$query = "SELECT ID, post_title, post_content FROM wp_posts
WHERE post_status = 'publish'
AND post_type IN ('post', 'page')
AND post_content IS NOT NULL
AND post_content != ''
ORDER BY ID LIMIT 200";
$result = $conn->query($query);
$tested = 0;
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['post_content']);
if (!empty($issues) && $tested < 5) {
$tested++;
echo "POST ID: {$row['ID']}\n";
echo "Título: {$row['post_title']}\n";
echo "Problemas detectados: " . count($issues) . "\n\n";
$fixResult = fixMalformedLists($row['post_content']);
$issuesAfter = detectIssues($fixResult['html']);
echo "ANTES: " . count($issues) . " problemas\n";
echo "DESPUÉS: " . count($issuesAfter) . " problemas\n";
echo "Cambios: {$fixResult['changes']}\n";
// Verificar integridad
$before_ul = substr_count($row['post_content'], '<ul');
$after_ul = substr_count($fixResult['html'], '<ul');
$before_li = substr_count($row['post_content'], '<li');
$after_li = substr_count($fixResult['html'], '<li');
echo "Tags <ul>: $before_ul$after_ul " . ($before_ul === $after_ul ? "" : "⚠️") . "\n";
echo "Tags <li>: $before_li$after_li " . ($before_li === $after_li ? "" : "⚠️") . "\n";
if (count($issuesAfter) === 0) {
echo "✅ CORRECCIÓN EXITOSA\n";
} else {
echo "⚠️ REQUIERE REVISIÓN\n";
}
echo "─────────────────────────────────\n\n";
}
}
} elseif ($mode === 'fix') {
echo "MODO: CORRECCIÓN (GUARDANDO CAMBIOS)\n";
echo "─────────────────────────────────\n\n";
$batch_size = 50;
$offset = 0;
$fixed_count = 0;
$error_count = 0;
while ($offset < $total) {
$query = "SELECT ID, post_content FROM wp_posts
WHERE post_status = 'publish'
AND post_type IN ('post', 'page')
AND post_content IS NOT NULL
AND post_content != ''
ORDER BY ID LIMIT $batch_size OFFSET $offset";
$result = $conn->query($query);
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['post_content']);
if (!empty($issues)) {
$fixResult = fixMalformedLists($row['post_content']);
if ($fixResult['fixed']) {
$stmt = $conn->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
$stmt->bind_param("si", $fixResult['html'], $row['ID']);
if ($stmt->execute()) {
$fixed_count++;
echo "[ID: {$row['ID']}] ✓ Corregido ({$fixResult['changes']} cambios)\n";
} else {
$error_count++;
echo "[ID: {$row['ID']}] ✗ Error al guardar\n";
}
$stmt->close();
}
}
}
$offset += $batch_size;
if ($offset % 500 == 0) {
echo "Procesados: $offset/$total (corregidos: $fixed_count)\n";
}
}
echo "\n─────────────────────────────────\n";
echo "RESUMEN:\n";
echo " Posts corregidos: $fixed_count\n";
echo " Errores: $error_count\n";
}
$conn->close();
echo "\n✓ Proceso completado.\n";

View File

@@ -1,307 +0,0 @@
<?php
/**
* Script de Diagnóstico: Listas HTML Mal Formadas
*
* PROPÓSITO: Identificar posts con estructura de listas inválida
* - <ul> conteniendo <ul> como hijo directo (en lugar de dentro de <li>)
* - <ol> conteniendo <ol> como hijo directo
*
* BASE DE DATOS: preciosunitarios_seo
* TABLA: datos_seo_pagina
* CAMPO: html
*
* IMPORTANTE: Este script SOLO LEE, no modifica ningún dato.
*
* @package ROI_Theme
* @since Phase 4.4 Accessibility
*/
// Configuración de errores para debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('memory_limit', '512M');
set_time_limit(300); // 5 minutos máximo
// Credenciales de base de datos (ajustar según servidor)
$db_config = [
'host' => 'localhost',
'database' => 'preciosunitarios_seo',
'username' => 'root', // Cambiar en producción
'password' => '', // Cambiar en producción
'charset' => 'utf8mb4'
];
// Patrones regex para detectar listas mal formadas
$malformed_patterns = [
// <ul> seguido directamente de <ul> (sin estar dentro de <li>)
'ul_direct_ul' => '/<ul[^>]*>\s*(?:<li[^>]*>.*?<\/li>\s*)*<ul/is',
// Patrón más específico: </li> seguido de <ul> (hermanos en lugar de anidados)
'li_sibling_ul' => '/<\/li>\s*<ul[^>]*>/is',
// <ol> seguido directamente de <ol>
'ol_direct_ol' => '/<ol[^>]*>\s*(?:<li[^>]*>.*?<\/li>\s*)*<ol/is',
// </li> seguido de <ol> (hermanos)
'li_sibling_ol' => '/<\/li>\s*<ol[^>]*>/is',
];
/**
* Conectar a la base de datos
*/
function connectDatabase(array $config): ?mysqli {
$conn = new mysqli(
$config['host'],
$config['username'],
$config['password'],
$config['database']
);
if ($conn->connect_error) {
echo "Error de conexión: " . $conn->connect_error . "\n";
return null;
}
$conn->set_charset($config['charset']);
return $conn;
}
/**
* Analizar HTML en busca de listas mal formadas
*/
function analyzeMalformedLists(string $html, array $patterns): array {
$issues = [];
foreach ($patterns as $pattern_name => $pattern) {
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$position = $match[1];
$context = getContextAroundPosition($html, $position, 100);
$issues[] = [
'type' => $pattern_name,
'position' => $position,
'context' => $context
];
}
}
}
return $issues;
}
/**
* Obtener contexto alrededor de una posición
*/
function getContextAroundPosition(string $html, int $position, int $length = 100): string {
$start = max(0, $position - $length);
$end = min(strlen($html), $position + $length);
$context = substr($html, $start, $end - $start);
// Limpiar para mostrar
$context = preg_replace('/\s+/', ' ', $context);
$context = htmlspecialchars($context);
if ($start > 0) {
$context = '...' . $context;
}
if ($end < strlen($html)) {
$context .= '...';
}
return $context;
}
/**
* Contar total de listas en el HTML
*/
function countListElements(string $html): array {
$ul_count = preg_match_all('/<ul[^>]*>/i', $html);
$ol_count = preg_match_all('/<ol[^>]*>/i', $html);
$li_count = preg_match_all('/<li[^>]*>/i', $html);
return [
'ul' => $ul_count,
'ol' => $ol_count,
'li' => $li_count
];
}
// ============================================
// EJECUCIÓN PRINCIPAL
// ============================================
echo "==============================================\n";
echo " DIAGNÓSTICO: Listas HTML Mal Formadas\n";
echo " Base de datos: {$db_config['database']}\n";
echo " Tabla: datos_seo_pagina\n";
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
echo "==============================================\n\n";
// Conectar
$conn = connectDatabase($db_config);
if (!$conn) {
exit(1);
}
echo "✓ Conexión establecida\n\n";
// Obtener estructura de la tabla
echo "Verificando estructura de tabla...\n";
$result = $conn->query("DESCRIBE datos_seo_pagina");
if ($result) {
echo "Columnas encontradas:\n";
while ($row = $result->fetch_assoc()) {
echo " - {$row['Field']} ({$row['Type']})\n";
}
echo "\n";
}
// Contar registros totales
$result = $conn->query("SELECT COUNT(*) as total FROM datos_seo_pagina WHERE html IS NOT NULL AND html != ''");
$total = $result->fetch_assoc()['total'];
echo "Total de registros con HTML: {$total}\n\n";
// Procesar en lotes
$batch_size = 100;
$offset = 0;
$affected_posts = [];
$total_issues = 0;
$processed = 0;
echo "Iniciando análisis...\n";
echo "─────────────────────────────────────────────\n";
while ($offset < $total) {
$query = "SELECT id, page, html FROM datos_seo_pagina
WHERE html IS NOT NULL AND html != ''
ORDER BY id
LIMIT {$batch_size} OFFSET {$offset}";
$result = $conn->query($query);
if (!$result) {
echo "Error en consulta: " . $conn->error . "\n";
break;
}
while ($row = $result->fetch_assoc()) {
$processed++;
$id = $row['id'];
$url = $row['page'] ?? 'N/A';
$html = $row['html'];
$issues = analyzeMalformedLists($html, $malformed_patterns);
if (!empty($issues)) {
$list_counts = countListElements($html);
$affected_posts[] = [
'id' => $id,
'url' => $url,
'issues' => $issues,
'list_counts' => $list_counts
];
$total_issues += count($issues);
// Mostrar progreso para posts afectados
echo "\n[ID: {$id}] " . count($issues) . " problema(s) encontrado(s)\n";
echo "URL: {$url}\n";
echo "Listas: UL={$list_counts['ul']}, OL={$list_counts['ol']}, LI={$list_counts['li']}\n";
foreach ($issues as $idx => $issue) {
echo " Problema " . ($idx + 1) . ": {$issue['type']} (pos: {$issue['position']})\n";
}
}
// Mostrar progreso cada 500 registros
if ($processed % 500 == 0) {
echo "\rProcesados: {$processed}/{$total}...";
}
}
$offset += $batch_size;
}
echo "\n\n";
echo "==============================================\n";
echo " RESUMEN DEL ANÁLISIS\n";
echo "==============================================\n\n";
echo "Registros analizados: {$processed}\n";
echo "Posts con problemas: " . count($affected_posts) . "\n";
echo "Total de incidencias: {$total_issues}\n\n";
if (count($affected_posts) > 0) {
echo "─────────────────────────────────────────────\n";
echo "DETALLE DE POSTS AFECTADOS\n";
echo "─────────────────────────────────────────────\n\n";
// Agrupar por tipo de problema
$by_type = [];
foreach ($affected_posts as $post) {
foreach ($post['issues'] as $issue) {
$type = $issue['type'];
if (!isset($by_type[$type])) {
$by_type[$type] = [];
}
$by_type[$type][] = $post['id'];
}
}
echo "Por tipo de problema:\n";
foreach ($by_type as $type => $ids) {
$unique_ids = array_unique($ids);
echo " - {$type}: " . count($unique_ids) . " posts\n";
}
echo "\n─────────────────────────────────────────────\n";
echo "LISTA DE IDs AFECTADOS (para revisión manual)\n";
echo "─────────────────────────────────────────────\n\n";
$ids_list = array_column($affected_posts, 'id');
echo "IDs: " . implode(', ', $ids_list) . "\n";
// Generar archivo de reporte
$report_file = __DIR__ . '/malformed-lists-report-' . date('Ymd-His') . '.json';
$report_data = [
'generated_at' => date('Y-m-d H:i:s'),
'database' => $db_config['database'],
'table' => 'datos_seo_pagina',
'total_analyzed' => $processed,
'total_affected' => count($affected_posts),
'total_issues' => $total_issues,
'by_type' => array_map(function($ids) {
return array_values(array_unique($ids));
}, $by_type),
'affected_posts' => $affected_posts
];
if (file_put_contents($report_file, json_encode($report_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
echo "\n✓ Reporte JSON guardado en:\n {$report_file}\n";
}
// Muestra de contexto para análisis
echo "\n─────────────────────────────────────────────\n";
echo "MUESTRA DE CONTEXTO (primeros 3 posts)\n";
echo "─────────────────────────────────────────────\n\n";
$sample = array_slice($affected_posts, 0, 3);
foreach ($sample as $post) {
echo "POST ID: {$post['id']}\n";
echo "URL: {$post['url']}\n";
foreach ($post['issues'] as $idx => $issue) {
echo " [{$issue['type']}]\n";
echo " Contexto: {$issue['context']}\n\n";
}
echo "───────────────────────\n";
}
} else {
echo "✓ No se encontraron listas mal formadas.\n";
}
$conn->close();
echo "\n✓ Análisis completado.\n";

View File

@@ -1,91 +0,0 @@
<?php
/**
* Script de PRUEBA - Muestra corrección propuesta sin aplicarla
*
* IMPORTANTE: Este script SOLO MUESTRA, no modifica nada.
*/
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
$conn->set_charset("utf8mb4");
echo "========================================\n";
echo "ANÁLISIS DE CORRECCIÓN PROPUESTA\n";
echo "========================================\n\n";
// Patrón que encuentra: </li></ul><li>TEXTO</li><ul>
// Este patrón captura:
// - $1: </li> inicial (con espacios)
// - $2: espacios entre </ul> y <li>
// - $3: contenido del <li> (ej: <strong>Texto</strong>)
// - $4: espacios entre </li> y <ul>
$pattern = '#(</li>\s*)</ul>(\s*)<li>(.*?)</li>(\s*)<ul>#is';
$replacement = '$1<li>$3$4<ul>';
echo "PATRÓN A BUSCAR:\n";
echo " </li>\\s*</ul>\\s*<li>CONTENIDO</li>\\s*<ul>\n\n";
echo "REEMPLAZO:\n";
echo " </li><li>CONTENIDO<ul>\n\n";
// Obtener HTML del post ID 3
$result = $conn->query("SELECT id, page, html FROM datos_seo_pagina WHERE id = 3");
$row = $result->fetch_assoc();
$html = $row["html"];
$page = $row["page"];
echo "PROBANDO CON POST ID 3:\n";
echo "URL: $page\n";
echo "────────────────────────────\n\n";
// Encontrar todas las ocurrencias
preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
echo "Ocurrencias encontradas: " . count($matches) . "\n\n";
// Mostrar cada ocurrencia y su corrección propuesta
foreach (array_slice($matches, 0, 3) as $idx => $match) {
$full_match = $match[0][0];
$position = $match[0][1];
echo "[$idx] Posición: $position\n";
echo "ANTES:\n";
echo htmlspecialchars($full_match) . "\n\n";
$fixed = preg_replace($pattern, $replacement, $full_match);
echo "DESPUÉS:\n";
echo htmlspecialchars($fixed) . "\n";
echo "────────────────────────────\n\n";
}
// Aplicar corrección en memoria y contar diferencia
$html_fixed = preg_replace($pattern, $replacement, $html);
$before = preg_match_all($pattern, $html);
$after = preg_match_all($pattern, $html_fixed);
echo "========================================\n";
echo "RESUMEN DE CORRECCIÓN (sin aplicar):\n";
echo "========================================\n";
echo "Ocurrencias ANTES: $before\n";
echo "Ocurrencias DESPUÉS: $after\n";
echo "Reducción: " . ($before - $after) . "\n\n";
// Verificar que la estructura es válida después de la corrección
$ul_count_before = substr_count($html, '<ul');
$ul_count_after = substr_count($html_fixed, '<ul');
echo "Tags <ul> antes: $ul_count_before\n";
echo "Tags <ul> después: $ul_count_after\n";
$li_count_before = substr_count($html, '<li');
$li_count_after = substr_count($html_fixed, '<li');
echo "Tags <li> antes: $li_count_before\n";
echo "Tags <li> después: $li_count_after\n";
echo "\n========================================\n";
echo "NOTA: Este patrón elimina el </ul> prematuro\n";
echo "pero NO agrega el </li> faltante al final.\n";
echo "Se necesita un segundo paso para balancear.\n";
echo "========================================\n";
$conn->close();

View File

@@ -1,187 +0,0 @@
<?php
/**
* Prueba corrección en casos específicos variados
*/
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
$conn->set_charset("utf8mb4");
// IDs a probar (casos variados)
$test_ids = [20, 23, 65, 377, 98, 107, 144];
function detectIssues($html) {
$issues = [];
libxml_use_internal_errors(true);
$doc = new DOMDocument("1.0", "UTF-8");
$doc->loadHTML('<?xml encoding="UTF-8"><div id="w">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$validChildren = ["li", "script", "template"];
foreach (["ul", "ol"] as $tag) {
foreach ($doc->getElementsByTagName($tag) as $list) {
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$childTag = strtolower($child->nodeName);
if (!in_array($childTag, $validChildren)) {
$issues[] = "<$tag> contiene <$childTag>";
}
}
}
}
}
return $issues;
}
function fixMalformedLists($html) {
$result = ['fixed' => false, 'html' => $html, 'changes' => 0];
libxml_use_internal_errors(true);
$doc = new DOMDocument("1.0", "UTF-8");
$doc->loadHTML('<?xml encoding="UTF-8"><div id="w">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$lists = [];
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
$changes = 0;
$validChildren = ["li", "script", "template"];
foreach ($lists as $list) {
$nodesToProcess = [];
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$nodesToProcess[] = $child;
}
}
}
foreach ($nodesToProcess as $node) {
$tagName = strtolower($node->nodeName);
$prevLi = null;
$prev = $node->previousSibling;
while ($prev) {
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
$prevLi = $prev;
break;
}
$prev = $prev->previousSibling;
}
if ($prevLi) {
$prevLi->appendChild($node);
$changes++;
} else {
$newLi = $doc->createElement('li');
$list->insertBefore($newLi, $node);
$newLi->appendChild($node);
$changes++;
}
}
}
if ($changes > 0) {
$wrapper = $doc->getElementById('w');
if ($wrapper) {
$innerHTML = '';
foreach ($wrapper->childNodes as $child) {
$innerHTML .= $doc->saveHTML($child);
}
$result['html'] = $innerHTML;
$result['fixed'] = true;
$result['changes'] = $changes;
}
}
return $result;
}
echo "=====================================================\n";
echo " PRUEBA DE CORRECCIÓN EN CASOS VARIADOS\n";
echo "=====================================================\n\n";
$ids_str = implode(',', $test_ids);
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE id IN ($ids_str)";
$result = $conn->query($query);
$all_passed = true;
while ($row = $result->fetch_assoc()) {
$id = $row['id'];
$url = $row['page'];
$html = $row['html'];
echo "─────────────────────────────────────────────────\n";
echo "POST ID: $id\n";
echo "URL: $url\n\n";
// Detectar problemas antes
$issues_before = detectIssues($html);
echo "ANTES:\n";
echo " Problemas: " . count($issues_before) . "\n";
$unique_types = array_unique($issues_before);
foreach ($unique_types as $type) {
echo " - $type\n";
}
// Aplicar corrección
$fixResult = fixMalformedLists($html);
// Detectar problemas después
$issues_after = detectIssues($fixResult['html']);
echo "\nDESPUÉS:\n";
echo " Cambios aplicados: {$fixResult['changes']}\n";
echo " Problemas restantes: " . count($issues_after) . "\n";
if (count($issues_after) > 0) {
echo " ⚠️ Problemas NO resueltos:\n";
foreach (array_unique($issues_after) as $type) {
echo " - $type\n";
}
$all_passed = false;
}
// Verificar integridad del HTML
$tags_before = [
'ul' => substr_count($html, '<ul'),
'ol' => substr_count($html, '<ol'),
'li' => substr_count($html, '<li'),
];
$tags_after = [
'ul' => substr_count($fixResult['html'], '<ul'),
'ol' => substr_count($fixResult['html'], '<ol'),
'li' => substr_count($fixResult['html'], '<li'),
];
echo "\nINTEGRIDAD DE TAGS:\n";
echo " <ul>: {$tags_before['ul']}{$tags_after['ul']} ";
echo ($tags_before['ul'] === $tags_after['ul'] ? "" : "⚠️ CAMBIÓ") . "\n";
echo " <ol>: {$tags_before['ol']}{$tags_after['ol']} ";
echo ($tags_before['ol'] === $tags_after['ol'] ? "" : "⚠️ CAMBIÓ") . "\n";
echo " <li>: {$tags_before['li']}{$tags_after['li']} ";
echo ($tags_before['li'] === $tags_after['li'] ? "" : "⚠️ CAMBIÓ") . "\n";
// Resultado
if (count($issues_after) === 0 &&
$tags_before['ul'] === $tags_after['ul'] &&
$tags_before['ol'] === $tags_after['ol']) {
echo "\n✅ RESULTADO: CORRECCIÓN EXITOSA\n";
} else {
echo "\n❌ RESULTADO: REQUIERE REVISIÓN\n";
$all_passed = false;
}
}
echo "\n=====================================================\n";
if ($all_passed) {
echo "✅ TODOS LOS CASOS PASARON LA PRUEBA\n";
} else {
echo "⚠️ ALGUNOS CASOS REQUIEREN REVISIÓN\n";
}
echo "=====================================================\n";
$conn->close();

View File

@@ -1,347 +0,0 @@
<?php
/**
* Validador de Correcciones - Genera archivos HTML para revisión visual
*
* PROPÓSITO: Crear archivos comparativos ANTES/DESPUÉS para validar
* que la corrección no rompe el contenido.
*
* USO: php validate-fix-lists.php
*
* GENERA:
* /tmp/list-fix-validation/
* ├── post_ID_before.html
* ├── post_ID_after.html
* └── comparison_report.html
*
* @package ROI_Theme
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('memory_limit', '256M');
$db_config = [
'host' => 'localhost',
'database' => 'preciosunitarios_seo',
'username' => 'preciosunitarios_seo',
'password' => 'ACl%EEFd=V-Yvb??',
'charset' => 'utf8mb4'
];
$output_dir = '/tmp/list-fix-validation';
$sample_size = 5;
echo "==============================================\n";
echo " VALIDADOR DE CORRECCIONES\n";
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
echo "==============================================\n\n";
// Crear directorio de salida
if (!is_dir($output_dir)) {
mkdir($output_dir, 0755, true);
}
// Limpiar archivos anteriores
array_map('unlink', glob("$output_dir/*.html"));
/**
* Detectar problemas en HTML
*/
function detectIssues(string $html): array {
$issues = [];
libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$validChildren = ['li', 'script', 'template'];
foreach (['ul', 'ol'] as $listTag) {
foreach ($doc->getElementsByTagName($listTag) as $list) {
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$issues[] = "<$listTag> contiene <$tagName>";
}
}
}
}
}
return $issues;
}
/**
* Corregir listas mal formadas
*/
function fixMalformedLists(string $html): array {
$result = ['fixed' => false, 'html' => $html, 'changes' => 0, 'details' => []];
libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$lists = [];
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
$changes = 0;
$validChildren = ['li', 'script', 'template'];
foreach ($lists as $list) {
$nodesToProcess = [];
foreach ($list->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tagName = strtolower($child->nodeName);
if (!in_array($tagName, $validChildren)) {
$nodesToProcess[] = $child;
}
}
}
foreach ($nodesToProcess as $node) {
$tagName = strtolower($node->nodeName);
$prevLi = null;
$prev = $node->previousSibling;
while ($prev) {
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
$prevLi = $prev;
break;
}
$prev = $prev->previousSibling;
}
if ($prevLi) {
$prevLi->appendChild($node);
$result['details'][] = "Movido <$tagName> dentro del <li> anterior";
$changes++;
} else {
$newLi = $doc->createElement('li');
$list->insertBefore($newLi, $node);
$newLi->appendChild($node);
$result['details'][] = "Envuelto <$tagName> en nuevo <li>";
$changes++;
}
}
}
if ($changes > 0) {
$wrapper = $doc->getElementById('temp-wrapper');
if ($wrapper) {
$innerHTML = '';
foreach ($wrapper->childNodes as $child) {
$innerHTML .= $doc->saveHTML($child);
}
$result['html'] = $innerHTML;
$result['fixed'] = true;
$result['changes'] = $changes;
}
}
return $result;
}
/**
* Generar HTML wrapper para visualización
*/
function wrapForVisualization(string $content, string $title, string $status): string {
$statusColor = $status === 'error' ? '#dc3545' : '#28a745';
return <<<HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; line-height: 1.6; }
.status { padding: 10px 20px; background: $statusColor; color: white; border-radius: 4px; margin-bottom: 20px; }
.content { border: 1px solid #ddd; padding: 20px; border-radius: 4px; background: #fafafa; }
ul, ol { background: #fff3cd; padding: 15px 15px 15px 35px; border-left: 4px solid #ffc107; margin: 10px 0; }
li { background: #d4edda; padding: 5px 10px; margin: 5px 0; border-left: 3px solid #28a745; }
h1, h2, h3, h4, h5, h6 { color: #333; }
p { color: #555; }
</style>
</head>
<body>
<div class="status">$status</div>
<div class="content">
$content
</div>
</body>
</html>
HTML;
}
// Conectar a DB
$conn = new mysqli($db_config['host'], $db_config['username'], $db_config['password'], $db_config['database']);
$conn->set_charset($db_config['charset']);
if ($conn->connect_error) {
die("Error de conexión: " . $conn->connect_error);
}
echo "✓ Conexión establecida\n\n";
// Buscar posts con problemas
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE html IS NOT NULL AND html != '' ORDER BY id LIMIT 500";
$result = $conn->query($query);
$samples = [];
while ($row = $result->fetch_assoc()) {
$issues = detectIssues($row['html']);
if (!empty($issues) && count($samples) < $sample_size) {
$samples[] = $row;
}
}
echo "Encontrados " . count($samples) . " posts con problemas para validar\n\n";
$comparison_data = [];
foreach ($samples as $idx => $post) {
$id = $post['id'];
$url = $post['page'];
$html_before = $post['html'];
echo "─────────────────────────────────\n";
echo "POST $id: $url\n";
// Detectar problemas antes
$issues_before = detectIssues($html_before);
echo " Problemas ANTES: " . count($issues_before) . "\n";
// Aplicar corrección
$fixResult = fixMalformedLists($html_before);
$html_after = $fixResult['html'];
// Detectar problemas después
$issues_after = detectIssues($html_after);
echo " Problemas DESPUÉS: " . count($issues_after) . "\n";
echo " Cambios aplicados: " . $fixResult['changes'] . "\n";
// Guardar archivos HTML
$file_before = "$output_dir/post_{$id}_BEFORE.html";
$file_after = "$output_dir/post_{$id}_AFTER.html";
file_put_contents($file_before, wrapForVisualization(
$html_before,
"Post $id - ANTES (con errores)",
"ANTES: " . count($issues_before) . " problemas de listas"
));
file_put_contents($file_after, wrapForVisualization(
$html_after,
"Post $id - DESPUÉS (corregido)",
"DESPUÉS: " . count($issues_after) . " problemas - " . $fixResult['changes'] . " correcciones aplicadas"
));
echo " ✓ Archivos generados:\n";
echo " - $file_before\n";
echo " - $file_after\n";
// Guardar datos para reporte
$comparison_data[] = [
'id' => $id,
'url' => $url,
'issues_before' => count($issues_before),
'issues_after' => count($issues_after),
'changes' => $fixResult['changes'],
'file_before' => "post_{$id}_BEFORE.html",
'file_after' => "post_{$id}_AFTER.html"
];
}
// Generar reporte comparativo
$report_html = <<<HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Reporte de Validación - Corrección de Listas</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
h1 { color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { padding: 12px; text-align: left; border: 1px solid #ddd; }
th { background: #007bff; color: white; }
tr:nth-child(even) { background: #f8f9fa; }
.success { color: #28a745; font-weight: bold; }
.warning { color: #ffc107; font-weight: bold; }
.error { color: #dc3545; font-weight: bold; }
a { color: #007bff; text-decoration: none; }
a:hover { text-decoration: underline; }
.instructions { background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 20px 0; }
</style>
</head>
<body>
<h1>Reporte de Validación - Corrección de Listas HTML</h1>
<div class="instructions">
<strong>Instrucciones:</strong>
<ol>
<li>Abre cada par de archivos (ANTES/DESPUÉS) en el navegador</li>
<li>Verifica que el contenido se muestre correctamente</li>
<li>Las listas (fondo amarillo) deben contener solo items (fondo verde)</li>
<li>Si todo se ve bien, la corrección es segura</li>
</ol>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>URL</th>
<th>Problemas Antes</th>
<th>Problemas Después</th>
<th>Cambios</th>
<th>Archivos</th>
</tr>
</thead>
<tbody>
HTML;
foreach ($comparison_data as $data) {
$status_class = $data['issues_after'] == 0 ? 'success' : ($data['issues_after'] < $data['issues_before'] ? 'warning' : 'error');
$report_html .= <<<HTML
<tr>
<td>{$data['id']}</td>
<td><a href="{$data['url']}" target="_blank">{$data['url']}</a></td>
<td class="error">{$data['issues_before']}</td>
<td class="$status_class">{$data['issues_after']}</td>
<td>{$data['changes']}</td>
<td>
<a href="{$data['file_before']}" target="_blank">ANTES</a> |
<a href="{$data['file_after']}" target="_blank">DESPUÉS</a>
</td>
</tr>
HTML;
}
$report_html .= <<<HTML
</tbody>
</table>
<p><strong>Generado:</strong> {$_SERVER['REQUEST_TIME_FLOAT']}</p>
</body>
</html>
HTML;
$report_file = "$output_dir/comparison_report.html";
file_put_contents($report_file, $report_html);
echo "\n─────────────────────────────────\n";
echo "REPORTE GENERADO:\n";
echo " $report_file\n\n";
echo "Para revisar, descarga el directorio:\n";
echo " scp -r VPSContabo:$output_dir ./validation/\n\n";
$conn->close();
echo "✓ Validación completada.\n";

View File

@@ -20,7 +20,7 @@ final class MigratePageVisibilityService
) {}
/**
* Ejecuta la migración para todos los componentes
* Ejecuta la migracion para todos los componentes
*
* @return array{created: int, skipped: int}
*/
@@ -37,10 +37,10 @@ final class MigratePageVisibilityService
continue;
}
// Usar constante compartida (DRY)
// Usar defaults especificos por componente si existen
$this->visibilityRepository->createDefaultVisibility(
$componentName,
VisibilityDefaults::DEFAULT_VISIBILITY
VisibilityDefaults::getForComponent($componentName)
);
$created++;
}

View File

@@ -40,7 +40,10 @@ final class WordPressPageTypeDetector implements PageTypeDetectorInterface
public function isHome(): bool
{
return is_front_page();
// is_front_page() = pagina de inicio configurada
// is_home() = pagina de posts (blog)
// Ambas cuentan como "home" para visibilidad
return is_front_page() || is_home();
}
public function isPost(): bool

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
/**
* Implementacion de PostGridShortcodeRendererInterface
*
* RESPONSABILIDAD: Generar HTML y CSS del shortcode [roi_post_grid].
* No construye queries ni obtiene settings de BD.
*
* @package ROITheme\Shared\Infrastructure\Ui
*/
final class PostGridShortcodeRenderer implements PostGridShortcodeRendererInterface
{
public function __construct(
private readonly CSSGeneratorInterface $cssGenerator
) {}
/**
* {@inheritdoc}
*/
public function render(\WP_Query $query, array $settings, array $options): string
{
if (!$query->have_posts()) {
return $this->renderNoPostsMessage($settings, $options);
}
$css = $this->generateCSS($settings, $options);
$html = $this->buildHTML($query, $settings, $options);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
private function renderNoPostsMessage(array $settings, array $options): string
{
$colors = $settings['colors'] ?? [];
$message = 'No se encontraron publicaciones';
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
$textColor = $colors['excerpt_color'] ?? '#6b7280';
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
$selector = $this->getSelector($options);
$css = $this->cssGenerator->generate("{$selector} .no-posts", [
'background-color' => $bgColor,
'color' => $textColor,
'border' => "1px solid {$borderColor}",
'border-radius' => '0.5rem',
'padding' => '2rem',
'text-align' => 'center',
]);
$containerClass = $this->getContainerClass($options);
return sprintf(
"<style>%s</style>\n<div class=\"%s\"><div class=\"no-posts\"><p class=\"mb-0\">%s</p></div></div>",
$css,
esc_attr($containerClass),
esc_html($message)
);
}
private function getSelector(array $options): string
{
$id = $options['id'] ?? '';
return !empty($id)
? ".roi-post-grid-shortcode-{$id}"
: '.roi-post-grid-shortcode';
}
private function getContainerClass(array $options): string
{
$id = $options['id'] ?? '';
$customClass = $options['class'] ?? '';
$class = !empty($id)
? "roi-post-grid-shortcode roi-post-grid-shortcode-{$id}"
: 'roi-post-grid-shortcode';
if (!empty($customClass)) {
$class .= ' ' . sanitize_html_class($customClass);
}
return $class;
}
private function generateCSS(array $settings, array $options): string
{
$colors = $settings['colors'] ?? [];
$spacing = $settings['spacing'] ?? [];
$effects = $settings['visual_effects'] ?? [];
$typography = $settings['typography'] ?? [];
$selector = $this->getSelector($options);
$cssRules = [];
// Colores
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
$metaColor = $colors['meta_color'] ?? '#9ca3af';
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
// Spacing
$gridGap = $spacing['grid_gap'] ?? '1.5rem';
$cardPadding = $spacing['card_padding'] ?? '1.25rem';
// Visual effects
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
// Typography
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
$metaSize = $typography['meta_size'] ?? '0.8rem';
// Container
$cssRules[] = $this->cssGenerator->generate($selector, [
'margin-bottom' => '2rem',
]);
// Row
$cssRules[] = $this->cssGenerator->generate("{$selector} .row", [
'row-gap' => $gridGap,
]);
// Card
$cssRules[] = "{$selector} .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
border-radius: {$cardBorderRadius};
box-shadow: {$cardShadow};
transition: {$cardTransition};
height: 100%;
}";
$cssRules[] = "{$selector} .card:hover {
background: {$cardHoverBgColor};
border-color: {$cardHoverBorderColor};
box-shadow: {$cardHoverShadow};
transform: translateY(-2px);
}";
$cssRules[] = $this->cssGenerator->generate("{$selector} .card-body", [
'padding' => $cardPadding,
]);
$cssRules[] = "{$selector} .card-img-top {
border-radius: {$cardBorderRadius} {$cardBorderRadius} 0 0;
object-fit: cover;
width: 100%;
height: 200px;
}";
$cssRules[] = "{$selector} .card-title {
color: {$cardTitleColor};
font-size: {$cardTitleSize};
font-weight: {$cardTitleWeight};
line-height: 1.4;
margin-bottom: 0.75rem;
}";
$cssRules[] = "{$selector} a:hover .card-title {
color: {$cardHoverBorderColor};
}";
$cssRules[] = "{$selector} .card-text {
color: {$excerptColor};
font-size: {$excerptSize};
line-height: 1.6;
}";
$cssRules[] = "{$selector} .post-meta {
color: {$metaColor};
font-size: {$metaSize};
}";
$cssRules[] = "{$selector} .post-category {
background: {$categoryBgColor};
color: {$categoryTextColor};
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}";
return implode("\n", $cssRules);
}
private function buildHTML(\WP_Query $query, array $settings, array $options): string
{
$columns = (int) ($options['columns'] ?? 3);
$showThumbnail = $this->toBool($options['show_thumbnail'] ?? true);
$showExcerpt = $this->toBool($options['show_excerpt'] ?? true);
$showMeta = $this->toBool($options['show_meta'] ?? true);
$showCategories = $this->toBool($options['show_categories'] ?? true);
$excerptLength = (int) ($options['excerpt_length'] ?? 20);
$showPagination = $this->toBool($options['show_pagination'] ?? false);
$containerClass = $this->getContainerClass($options);
$colClass = $this->getColumnClass($columns);
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= '<div class="row">';
while ($query->have_posts()) {
$query->the_post();
$html .= $this->buildCardHTML(
$colClass,
$showThumbnail,
$showExcerpt,
$showMeta,
$showCategories,
$excerptLength
);
}
$html .= '</div>';
if ($showPagination && $query->max_num_pages > 1) {
$html .= $this->buildPaginationHTML($query, $options);
}
$html .= '</div>';
return $html;
}
private function getColumnClass(int $columns): string
{
return match ($columns) {
1 => 'col-12',
2 => 'col-12 col-md-6',
4 => 'col-12 col-md-6 col-lg-3',
default => 'col-12 col-md-6 col-lg-4',
};
}
private function buildCardHTML(
string $colClass,
bool $showThumbnail,
bool $showExcerpt,
bool $showMeta,
bool $showCategories,
int $excerptLength
): string {
$permalink = get_permalink();
$title = get_the_title();
$html = sprintf('<div class="%s mb-4">', esc_attr($colClass));
$html .= sprintf('<a href="%s" class="text-decoration-none">', esc_url($permalink));
$html .= '<div class="card h-100">';
if ($showThumbnail) {
$html .= $this->buildImageHTML();
}
$html .= '<div class="card-body">';
if ($showCategories) {
$html .= $this->buildCategoriesHTML();
}
$html .= sprintf('<h3 class="card-title">%s</h3>', esc_html($title));
if ($showMeta) {
$html .= $this->buildMetaHTML();
}
if ($showExcerpt) {
$html .= $this->buildExcerptHTML($excerptLength);
}
$html .= '</div></div></a></div>';
return $html;
}
private function buildImageHTML(): string
{
if (has_post_thumbnail()) {
return get_the_post_thumbnail(null, 'medium_large', [
'class' => 'card-img-top',
'loading' => 'lazy'
]);
}
return '';
}
private function buildCategoriesHTML(): string
{
$categories = get_the_category();
if (empty($categories)) {
return '';
}
$html = '<div class="post-categories mb-2">';
foreach (array_slice($categories, 0, 2) as $category) {
$html .= sprintf(
'<span class="post-category">%s</span>',
esc_html($category->name)
);
}
$html .= '</div>';
return $html;
}
private function buildMetaHTML(): string
{
return sprintf(
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
esc_html(get_the_date()),
esc_html(get_the_author())
);
}
private function buildExcerptHTML(int $length): string
{
$excerpt = get_the_excerpt();
if (empty($excerpt)) {
$excerpt = wp_trim_words(get_the_content(), $length, '...');
} else {
$excerpt = wp_trim_words($excerpt, $length, '...');
}
return sprintf('<p class="card-text">%s</p>', esc_html($excerpt));
}
private function buildPaginationHTML(\WP_Query $query, array $options): string
{
$id = $options['id'] ?? '';
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
$currentPage = max(1, (int) get_query_var($queryVar, 1));
$totalPages = $query->max_num_pages;
$html = '<nav class="pagination-wrapper mt-4"><ul class="pagination justify-content-center">';
for ($i = 1; $i <= $totalPages; $i++) {
$activeClass = ($i === $currentPage) ? ' active' : '';
$url = add_query_arg($queryVar, $i);
$html .= sprintf(
'<li class="page-item%s"><a class="page-link" href="%s">%d</a></li>',
$activeClass,
esc_url($url),
$i
);
}
$html .= '</ul></nav>';
return $html;
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return $value === 'true' || $value === '1' || $value === 1;
}
}

View File

@@ -1,697 +0,0 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\FormBuilderInterface;
/**
* TopNotificationBarFormBuilder - Construye formulario de configuración
*
* RESPONSABILIDAD: Generar formulario HTML del admin para Top Notification Bar
*
* CARACTERÍSTICAS:
* - 3 secciones: Visibilidad, Contenido, Estilos
* - 19 campos configurables
* - Lógica condicional (data-conditional-field)
* - WordPress Media Library integration
* - Vista previa en tiempo real
*
* @package ROITheme\Shared\Infrastructure\Ui
*/
final class TopNotificationBarFormBuilder implements FormBuilderInterface
{
public function build(Component $component): string
{
$data = $component->getData();
$componentId = $component->getName();
$html = '<div class="roi-form-builder roi-top-notification-bar-form">';
// Sección de Visibilidad
$html .= $this->buildVisibilitySection($data, $componentId);
// Sección de Contenido
$html .= $this->buildContentSection($data, $componentId);
// Sección de Estilos
$html .= $this->buildStylesSection($data, $componentId);
// Vista previa
$html .= $this->buildPreviewSection($data);
$html .= '</div>';
// Agregar scripts de formulario
$html .= $this->buildFormScripts($componentId);
return $html;
}
private function buildVisibilitySection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="visibility">';
$html .= '<h3 class="roi-form-section-title">Visibilidad</h3>';
$html .= '<div class="roi-form-section-content">';
// Is Enabled
$isEnabled = $data['visibility']['is_enabled'] ?? true;
$html .= $this->buildToggle(
'is_enabled',
'Mostrar barra de notificación',
$isEnabled,
$componentId,
'Activa o desactiva la barra de notificación superior'
);
// Show On Pages
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
$html .= $this->buildSelect(
'show_on_pages',
'Mostrar en',
$showOn,
[
'all' => 'Todas las páginas',
'home' => 'Solo página de inicio',
'posts' => 'Solo posts individuales',
'pages' => 'Solo páginas',
'custom' => 'Páginas específicas'
],
$componentId,
'Define en qué páginas se mostrará la barra'
);
// Custom Page IDs
$customPageIds = $data['visibility']['custom_page_ids'] ?? '';
$html .= $this->buildTextField(
'custom_page_ids',
'IDs de páginas específicas',
$customPageIds,
$componentId,
'IDs de páginas separados por comas',
'Ej: 1,5,10',
['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']
);
// Hide On Mobile
$hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false;
$html .= $this->buildToggle(
'hide_on_mobile',
'Ocultar en dispositivos móviles',
$hideOnMobile,
$componentId,
'Oculta la barra en pantallas menores a 768px'
);
// Is Dismissible
$isDismissible = $data['visibility']['is_dismissible'] ?? false;
$html .= $this->buildToggle(
'is_dismissible',
'Permitir cerrar',
$isDismissible,
$componentId,
'Agrega botón X para que el usuario pueda cerrar la barra'
);
// Dismissible Cookie Days
$cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7;
$html .= $this->buildNumberField(
'dismissible_cookie_days',
'Días antes de volver a mostrar',
$cookieDays,
$componentId,
'Días que permanece oculta después de cerrarla',
1,
365,
['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildContentSection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="content">';
$html .= '<h3 class="roi-form-section-title">Contenido</h3>';
$html .= '<div class="roi-form-section-content">';
// Icon Type
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
$html .= $this->buildSelect(
'icon_type',
'Tipo de ícono',
$iconType,
[
'bootstrap' => 'Bootstrap Icons',
'custom' => 'Imagen personalizada',
'none' => 'Sin ícono'
],
$componentId,
'Selecciona el tipo de ícono a mostrar'
);
// Bootstrap Icon
$bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
$html .= $this->buildTextField(
'bootstrap_icon',
'Clase de ícono Bootstrap',
$bootstrapIcon,
$componentId,
'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)',
'Ej: bi-megaphone-fill',
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap']
);
// Custom Icon URL
$customIconUrl = $data['content']['custom_icon_url'] ?? '';
$html .= $this->buildMediaField(
'custom_icon_url',
'Imagen personalizada',
$customIconUrl,
$componentId,
'Sube una imagen personalizada (recomendado: PNG 24x24px)',
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom']
);
// Announcement Label
$announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:';
$html .= $this->buildTextField(
'announcement_label',
'Etiqueta del anuncio',
$announcementLabel,
$componentId,
'Texto destacado en negrita antes del mensaje',
'Ej: Nuevo:, Importante:, Aviso:'
);
// Announcement Text
$announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
$html .= $this->buildTextArea(
'announcement_text',
'Texto del anuncio',
$announcementText,
$componentId,
'Mensaje principal del anuncio (máximo 200 caracteres)',
3
);
// Link Enabled
$linkEnabled = $data['content']['link_enabled'] ?? true;
$html .= $this->buildToggle(
'link_enabled',
'Mostrar enlace',
$linkEnabled,
$componentId,
'Activa o desactiva el enlace de acción'
);
// Link Text
$linkText = $data['content']['link_text'] ?? 'Ver Catálogo';
$html .= $this->buildTextField(
'link_text',
'Texto del enlace',
$linkText,
$componentId,
'Texto del enlace de acción',
'',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
// Link URL
$linkUrl = $data['content']['link_url'] ?? '#';
$html .= $this->buildUrlField(
'link_url',
'URL del enlace',
$linkUrl,
$componentId,
'URL de destino del enlace',
'https://',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
// Link Target
$linkTarget = $data['content']['link_target'] ?? '_self';
$html .= $this->buildSelect(
'link_target',
'Abrir enlace en',
$linkTarget,
[
'_self' => 'Misma ventana',
'_blank' => 'Nueva ventana'
],
$componentId,
'Define cómo se abrirá el enlace',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildStylesSection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="styles">';
$html .= '<h3 class="roi-form-section-title">Estilos</h3>';
$html .= '<div class="roi-form-section-content">';
// Background Color
$bgColor = $data['styles']['background_color'] ?? '#FF8600';
$html .= $this->buildColorField(
'background_color',
'Color de fondo',
$bgColor,
$componentId,
'Color de fondo de la barra (por defecto: orange primary)'
);
// Text Color
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField(
'text_color',
'Color del texto',
$textColor,
$componentId,
'Color del texto del anuncio'
);
// Link Color
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
$html .= $this->buildColorField(
'link_color',
'Color del enlace',
$linkColor,
$componentId,
'Color del enlace de acción'
);
// Font Size
$fontSize = $data['styles']['font_size'] ?? 'small';
$html .= $this->buildSelect(
'font_size',
'Tamaño de fuente',
$fontSize,
[
'extra-small' => 'Muy pequeño (0.75rem)',
'small' => 'Pequeño (0.875rem)',
'normal' => 'Normal (1rem)',
'large' => 'Grande (1.125rem)'
],
$componentId,
'Tamaño del texto del anuncio'
);
// Padding Vertical
$padding = $data['styles']['padding_vertical'] ?? 'normal';
$html .= $this->buildSelect(
'padding_vertical',
'Padding vertical',
$padding,
[
'compact' => 'Compacto (0.5rem)',
'normal' => 'Normal (0.75rem)',
'spacious' => 'Espacioso (1rem)'
],
$componentId,
'Espaciado vertical interno de la barra'
);
// Text Alignment
$alignment = $data['styles']['text_alignment'] ?? 'center';
$html .= $this->buildSelect(
'text_alignment',
'Alineación del texto',
$alignment,
[
'left' => 'Izquierda',
'center' => 'Centro',
'right' => 'Derecha'
],
$componentId,
'Alineación del contenido de la barra'
);
// Animation Enabled
$animationEnabled = $data['styles']['animation_enabled'] ?? false;
$html .= $this->buildToggle(
'animation_enabled',
'Activar animación',
$animationEnabled,
$componentId,
'Activa animación de entrada al cargar la página'
);
// Animation Type
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
$html .= $this->buildSelect(
'animation_type',
'Tipo de animación',
$animationType,
[
'slide-down' => 'Deslizar desde arriba',
'fade-in' => 'Aparecer gradualmente'
],
$componentId,
'Tipo de animación de entrada',
['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildPreviewSection(array $data): string
{
$html = '<div class="roi-form-section roi-preview-section">';
$html .= '<h3 class="roi-form-section-title">Vista Previa</h3>';
$html .= '<div class="roi-form-section-content">';
$html .= '<div id="roi-component-preview" class="border rounded p-3 bg-light">';
$html .= '<p class="text-muted">La vista previa se actualizará automáticamente al modificar los campos.</p>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string
{
$fieldId = "roi_{$componentId}_{$name}";
$checked = $value ? 'checked' : '';
$html = '<div class="roi-form-field roi-form-field-toggle mb-3">';
$html .= '<div class="form-check form-switch">';
$html .= sprintf(
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s]" value="1" %s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$checked
);
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '</div>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-text mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" placeholder="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
esc_attr($placeholder),
$attrString
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-textarea mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<textarea class="form-control" id="%s" name="roi_component[%s][%s]" rows="%d"%s>%s</textarea>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$rows,
$attrString,
esc_textarea($value)
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-select mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<select class="form-select" id="%s" name="roi_component[%s][%s]"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$attrString
);
foreach ($options as $optValue => $optLabel) {
$selected = ($value === $optValue) ? 'selected' : '';
$html .= sprintf(
'<option value="%s" %s>%s</option>',
esc_attr($optValue),
$selected,
esc_html($optLabel)
);
}
$html .= '</select>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-number mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrs['type'] = 'number';
if ($min !== null) {
$attrs['min'] = $min;
}
if ($max !== null) {
$attrs['max'] = $max;
}
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input class="form-control" id="%s" name="roi_component[%s][%s]" value="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
$attrString
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
{
$attrs['type'] = 'url';
return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs);
}
private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-color mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$html .= sprintf(
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s]" value="%s">',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value)
);
$html .= sprintf(
'<input type="text" class="form-control" value="%s" readonly>',
esc_attr($value)
);
$html .= '</div>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-media mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" readonly%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
$attrString
);
$html .= sprintf(
'<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>',
esc_attr($fieldId)
);
$html .= '</div>';
if (!empty($value)) {
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 100px; height: auto;"></div>', esc_url($value));
}
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildAttributesString(array $attrs): string
{
$attrString = '';
foreach ($attrs as $key => $value) {
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
}
return $attrString;
}
private function buildFormScripts(string $componentId): string
{
return <<<SCRIPT
<script>
(function($) {
'use strict';
$(document).ready(function() {
// Conditional logic
$('[data-conditional-field]').each(function() {
const field = $(this);
const targetFieldName = field.data('conditional-field');
const targetValue = field.data('conditional-value');
const targetField = $('[name*="[' + targetFieldName + ']"]');
function updateVisibility() {
let currentValue;
if (targetField.is(':checkbox')) {
currentValue = targetField.is(':checked') ? 'true' : 'false';
} else {
currentValue = targetField.val();
}
if (currentValue === targetValue) {
field.closest('.roi-form-field').show();
} else {
field.closest('.roi-form-field').hide();
}
}
targetField.on('change', updateVisibility);
updateVisibility();
});
// Media upload
$('.roi-media-upload-btn').on('click', function(e) {
e.preventDefault();
const button = $(this);
const targetId = button.data('target');
const targetField = $('#' + targetId);
const mediaUploader = wp.media({
title: 'Seleccionar imagen',
button: { text: 'Usar esta imagen' },
multiple: false
});
mediaUploader.on('select', function() {
const attachment = mediaUploader.state().get('selection').first().toJSON();
targetField.val(attachment.url);
const preview = targetField.closest('.roi-form-field-media').find('img');
if (preview.length) {
preview.attr('src', attachment.url);
} else {
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 100px; height: auto;"></div>');
}
});
mediaUploader.open();
});
// Color picker sync
$('.form-control-color').on('change', function() {
$(this).next('input[type="text"]').val($(this).val());
});
// Auto-update preview
$('.roi-form-field input, .roi-form-field select, .roi-form-field textarea').on('change keyup', function() {
updatePreview();
});
function updatePreview() {
// Aquí iría la lógica para actualizar la vista previa en tiempo real
console.log('Preview updated');
}
});
})(jQuery);
</script>
SCRIPT;
}
public function supports(string $componentType): bool
{
return $componentType === 'top-notification-bar';
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Wordpress;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridRequest;
/**
* Registra el shortcode [roi_post_grid] en WordPress
*
* RESPONSABILIDAD:
* - Registrar shortcode via add_shortcode
* - Sanitizar atributos del shortcode
* - Delegar ejecucion a RenderPostGridUseCase
*
* NO RESPONSABLE DE:
* - Logica de negocio
* - Construccion de queries
* - Generacion de HTML/CSS
*
* @package ROITheme\Shared\Infrastructure\Wordpress
*/
final class PostGridShortcodeRegistrar
{
private const SHORTCODE_TAG = 'roi_post_grid';
/**
* Registra el shortcode en WordPress
*/
public static function register(): void
{
add_shortcode(self::SHORTCODE_TAG, [new self(), 'handleShortcode']);
}
/**
* Callback del shortcode
*
* @param array|string $atts Atributos del shortcode
* @return string HTML del grid
*/
public function handleShortcode($atts): string
{
$atts = $this->sanitizeAttributes($atts);
// Obtener paged desde query var si existe
$id = $atts['id'] ?? '';
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
$atts['paged'] = max(1, (int) get_query_var($queryVar, 1));
// Crear request DTO
$request = RenderPostGridRequest::fromArray($atts);
// Obtener UseCase desde DIContainer
$container = DIContainer::getInstance();
$useCase = $container->getRenderPostGridUseCase();
return $useCase->execute($request);
}
/**
* Sanitiza atributos del shortcode
*
* @param array|string $atts
* @return array<string, mixed>
*/
private function sanitizeAttributes($atts): array
{
$atts = shortcode_atts([
'category' => '',
'exclude_category' => '',
'tag' => '',
'author' => '',
'posts_per_page' => '9',
'columns' => '3',
'orderby' => 'date',
'order' => 'DESC',
'show_pagination' => 'false',
'offset' => '0',
'exclude_posts' => '',
'show_thumbnail' => 'true',
'show_excerpt' => 'true',
'show_meta' => 'true',
'show_categories' => 'true',
'excerpt_length' => '20',
'class' => '',
'id' => '',
], $atts, self::SHORTCODE_TAG);
// Sanitizar cada valor
return array_map(function ($value) {
return is_string($value) ? sanitize_text_field($value) : $value;
}, $atts);
}
}

View File

@@ -1,22 +0,0 @@
<?php
/**
* Template Part: CTA Box Sidebar
*
* Caja de llamada a la acción naranja en el sidebar
* Abre el modal de contacto al hacer clic
*
* @package ROI_Theme
* @since 1.0.0
*/
?>
<!-- DEBUG: CTA Box Template Loaded -->
<!-- CTA Box Sidebar -->
<div class="cta-box-sidebar">
<h5 class="cta-box-title">¿Listo para potenciar tus proyectos?</h5>
<p class="cta-box-text">Accede a nuestra biblioteca completa de APUs y herramientas profesionales.</p>
<button class="btn btn-cta-box w-100" data-bs-toggle="modal" data-bs-target="#contactModal">
Solicitar Información
</button>
</div>
<!-- DEBUG: CTA Box Template End -->

View File

@@ -1,42 +0,0 @@
<?php
/**
* Template Part: Table of Contents (TOC)
*
* Genera automáticamente TOC desde los H2 del post
* HTML exacto del template original
*
* @package ROI_Theme
* @since 1.0.0
*/
// Solo mostrar TOC si estamos en single post
if (!is_single()) {
return;
}
// Obtener el contenido del post actual
global $post;
$post_content = $post->post_content;
// Aplicar filtros de WordPress al contenido
$post_content = apply_filters('the_content', $post_content);
// Buscar todos los H2 con ID en el contenido
preg_match_all('/<h2[^>]*id=["\']([^"\']*)["\'][^>]*>(.*?)<\/h2>/i', $post_content, $matches);
// Si no hay H2 con ID, no mostrar TOC
if (empty($matches[1])) {
return;
}
// Generar el TOC con el HTML del template
?>
<div class="toc-container">
<h4>Tabla de Contenido</h4>
<ol class="list-unstyled toc-list">
<?php foreach ($matches[1] as $index => $id) : ?>
<?php $title = strip_tags($matches[2][$index]); ?>
<li><a href="#<?php echo esc_attr($id); ?>"><?php echo esc_html($title); ?></a></li>
<?php endforeach; ?>
</ol>
</div>

View File

@@ -1,21 +0,0 @@
<?php
/**
* CTA Box Sidebar Template
*
* Aparece debajo del TOC en single posts
*
* @package ROI_Theme
*/
if (!is_single()) {
return;
}
?>
<div class="cta-box-sidebar mt-3">
<h5 class="cta-box-title">¿Listo para potenciar tus proyectos?</h5>
<p class="cta-box-text">Accede a nuestra biblioteca completa de APUs y herramientas profesionales.</p>
<button class="btn btn-cta-box w-100" data-bs-toggle="modal" data-bs-target="#contactModal">
<i class="bi bi-calendar-check me-2"></i>Solicitar Demo
</button>
</div>

View File

@@ -1,61 +0,0 @@
<?php
/**
* Modal de Contacto - Bootstrap 5
*
* Modal activado por botón "Let's Talk" y CTA Box Sidebar
*
* @package ROI_Theme
* @since 1.0.0
*/
?>
<!-- Contact Modal -->
<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contactModalLabel">
<i class="bi bi-envelope-fill me-2" style="color: #FF8600;"></i>
<?php esc_html_e( '¿Tienes alguna pregunta?', 'roi-theme' ); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php esc_attr_e( 'Cerrar', 'roi-theme' ); ?>"></button>
</div>
<div class="modal-body">
<p class="mb-4">
<?php esc_html_e( 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.', 'roi-theme' ); ?>
</p>
<form id="modalContactForm">
<div class="row g-3">
<div class="col-md-6">
<label for="modalFullName" class="form-label"><?php esc_html_e( 'Nombre completo', 'roi-theme' ); ?> *</label>
<input type="text" class="form-control" id="modalFullName" name="fullName" required>
</div>
<div class="col-md-6">
<label for="modalCompany" class="form-label"><?php esc_html_e( 'Empresa', 'roi-theme' ); ?></label>
<input type="text" class="form-control" id="modalCompany" name="company">
</div>
<div class="col-md-6">
<label for="modalWhatsapp" class="form-label"><?php esc_html_e( 'WhatsApp', 'roi-theme' ); ?> *</label>
<input type="tel" class="form-control" id="modalWhatsapp" name="whatsapp" required>
</div>
<div class="col-md-6">
<label for="modalEmail" class="form-label"><?php esc_html_e( 'Correo electrónico', 'roi-theme' ); ?> *</label>
<input type="email" class="form-control" id="modalEmail" name="email" required>
</div>
<div class="col-12">
<label for="modalComments" class="form-label"><?php esc_html_e( '¿En qué podemos ayudarte?', 'roi-theme' ); ?></label>
<textarea class="form-control" id="modalComments" name="comments" rows="4"></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send-fill me-2"></i><?php esc_html_e( 'Enviar Mensaje', 'roi-theme' ); ?>
</button>
</div>
<div id="modalFormMessage" class="col-12 mt-2 alert" style="display: none;"></div>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@@ -1,20 +0,0 @@
<?php
/**
* Top Notification Bar Component
*
* Barra de notificaciones superior del sitio
*
* @package ROI_Theme
* @since 2.0.0
*/
?>
<div class="top-notification-bar">
<div class="container">
<div class="d-flex align-items-center justify-content-center">
<i class="bi bi-megaphone-fill me-2"></i>
<span><strong>Nuevo:</strong> Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</span>
<a href="#" class="ms-2 text-white text-decoration-underline">Ver Catálogo</a>
</div>
</div>
</div>

View File

@@ -2,8 +2,9 @@
/**
* The template for displaying archive pages
*
* This template displays date-based, category, tag, author, and post type
* archives with a dynamic title, description, and post loop.
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#archive
*
@@ -14,213 +15,99 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Archive Header - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<?php if ( have_posts() ) : ?>
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Archive Header -->
<header class="page-header">
<?php
// Archive title
the_archive_title( '<h1 class="page-title">', '</h1>' );
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
// Archive description
$archive_description = get_the_archive_description();
if ( ! empty( $archive_description ) ) :
?>
<div class="archive-description">
<?php echo wp_kses_post( wpautop( $archive_description ) ); ?>
</div>
<?php endif; ?>
</header><!-- .page-header -->
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<!-- Archive Posts Loop -->
<div class="archive-posts">
<!-- Social Share - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('social-share');
}
?>
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
?>
<!-- CTA Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-post');
}
?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<!-- Related Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('related-post');
}
?>
<!-- Post Thumbnail -->
<?php if ( has_post_thumbnail() ) : ?>
<div class="post-thumbnail">
<a href="<?php the_permalink(); ?>" aria-hidden="true" tabindex="-1">
<?php
the_post_thumbnail(
'roi-featured-medium',
array(
'alt' => the_title_attribute(
array(
'echo' => false,
)
),
'loading' => 'lazy',
)
);
?>
</a>
</div>
<?php endif; ?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<!-- Post Content -->
<div class="post-content">
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<!-- Post Header -->
<header class="entry-header">
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
<!-- Category Badges -->
<?php
$categories = get_the_category();
if ( ! empty( $categories ) ) :
?>
<div class="entry-categories">
<?php foreach ( $categories as $category ) : ?>
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>"
class="category-badge"
rel="category tag">
<?php echo esc_html( $category->name ); ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><!-- .row -->
</div><!-- .container -->
<!-- Post Title -->
<?php
if ( is_singular() ) :
the_title( '<h1 class="entry-title">', '</h1>' );
else :
the_title(
sprintf(
'<h2 class="entry-title"><a href="%s" rel="bookmark">',
esc_url( get_permalink() )
),
'</a></h2>'
);
endif;
?>
</main><!-- #main-content -->
<!-- Post Meta -->
<div class="entry-meta">
<span class="posted-on">
<time class="entry-date published" datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>">
<?php echo esc_html( get_the_date() ); ?>
</time>
</span>
<span class="byline">
<span class="author vcard">
<a class="url fn n" href="<?php echo esc_url( get_author_posts_url( get_the_author_meta( 'ID' ) ) ); ?>">
<?php echo esc_html( get_the_author() ); ?>
</a>
</span>
</span>
</div><!-- .entry-meta -->
</header><!-- .entry-header -->
<!-- Post Excerpt -->
<div class="entry-summary">
<?php the_excerpt(); ?>
</div><!-- .entry-summary -->
<!-- Read More Link -->
<div class="entry-footer">
<a href="<?php the_permalink(); ?>" class="read-more-link">
<?php
printf(
wp_kses(
/* translators: %s: Post title. Only visible to screen readers. */
__( 'Continue reading<span class="screen-reader-text"> "%s"</span>', 'roi-theme' ),
array(
'span' => array(
'class' => array(),
),
)
),
get_the_title()
);
?>
<span class="read-more-icon" aria-hidden="true">&rarr;</span>
</a>
</div><!-- .entry-footer -->
</div><!-- .post-content -->
</article><!-- #post-<?php the_ID(); ?> -->
<?php endwhile; ?>
</div><!-- .archive-posts -->
<?php
/**
* Pagination
* Display navigation to next/previous set of posts when applicable.
*/
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => sprintf(
'%s <span class="nav-prev-text">%s</span>',
'<span class="nav-icon" aria-hidden="true">&laquo;</span>',
esc_html__( 'Previous', 'roi-theme' )
),
'next_text' => sprintf(
'<span class="nav-next-text">%s</span> %s',
esc_html__( 'Next', 'roi-theme' ),
'<span class="nav-icon" aria-hidden="true">&raquo;</span>'
),
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
)
);
else :
/**
* No posts found
* Display a message when no content is available.
*/
?>
<section class="no-results not-found">
<header class="page-header">
<h1 class="page-title">
<?php esc_html_e( 'Nothing Found', 'roi-theme' ); ?>
</h1>
</header><!-- .page-header -->
<div class="page-content">
<p>
<?php esc_html_e( 'It seems we can&rsquo;t find what you&rsquo;re looking for. Perhaps searching can help.', 'roi-theme' ); ?>
</p>
</div><!-- .page-content -->
</section><!-- .no-results -->
<?php endif; ?>
</div><!-- #primary -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
?>
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

View File

@@ -2,8 +2,9 @@
/**
* The template for displaying author archive pages
*
* This template displays posts from a specific author with author
* bio and information at the top.
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#author
*
@@ -14,150 +15,78 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Archive Header - Componente dinamico (detecta autor automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<?php if ( have_posts() ) : ?>
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Author Archive Header -->
<header class="page-header author-header">
<?php
// Get the author
$author = get_queried_object();
?>
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<div class="author-info">
<!-- Author Avatar -->
<div class="author-avatar">
<?php
echo get_avatar(
$author->ID,
120,
'',
sprintf(
/* translators: %s: author name */
esc_attr__( 'Avatar for %s', 'roi-theme' ),
esc_html( $author->display_name )
),
array(
'class' => 'author-avatar-img',
)
);
?>
</div>
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<!-- Author Details -->
<div class="author-details">
<h1 class="page-title author-title">
<?php
printf(
/* translators: %s: author display name */
esc_html__( 'Posts by %s', 'roi-theme' ),
'<span class="author-name">' . esc_html( $author->display_name ) . '</span>'
);
?>
</h1>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<!-- Author Bio -->
<?php
$author_bio = get_the_author_meta( 'description', $author->ID );
if ( ! empty( $author_bio ) ) :
?>
<div class="author-bio">
<?php echo wp_kses_post( wpautop( $author_bio ) ); ?>
</div>
<?php endif; ?>
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<!-- Author Stats -->
<div class="author-meta">
<span class="author-posts-count">
<?php
$post_count = count_user_posts( $author->ID, 'post', true );
printf(
/* translators: %s: number of posts */
esc_html( _n( '%s post', '%s posts', $post_count, 'roi-theme' ) ),
esc_html( number_format_i18n( $post_count ) )
);
?>
</span>
</div>
</div>
</div>
</header><!-- .page-header -->
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
<!-- Author Posts Loop -->
<div class="archive-posts author-posts">
</div><!-- .row -->
</div><!-- .container -->
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
</main><!-- #main-content -->
/**
* Include the Post-Type-specific template for the content.
* If you want to override this in a child theme, then include a file
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
*/
get_template_part( 'template-parts/content', get_post_type() );
endwhile;
?>
</div><!-- .archive-posts -->
<?php
/**
* Pagination
* Display navigation to next/previous set of posts when applicable.
*/
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => sprintf(
'%s <span class="nav-prev-text">%s</span>',
'<span class="nav-icon" aria-hidden="true">&laquo;</span>',
esc_html__( 'Previous', 'roi-theme' )
),
'next_text' => sprintf(
'<span class="nav-next-text">%s</span> %s',
esc_html__( 'Next', 'roi-theme' ),
'<span class="nav-icon" aria-hidden="true">&raquo;</span>'
),
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
)
);
else :
/**
* No posts found
* Display a message when no content is available.
*/
get_template_part( 'template-parts/content', 'none' );
endif;
?>
</div><!-- #primary -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
?>
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

View File

@@ -2,8 +2,9 @@
/**
* The template for displaying category archive pages
*
* This template displays posts from a specific category with category
* information and description at the top.
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#category
*
@@ -14,118 +15,99 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Archive Header - Componente dinamico (detecta categoria automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<?php if ( have_posts() ) : ?>
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Category Archive Header -->
<header class="page-header category-header">
<?php
// Category title
the_archive_title( '<h1 class="page-title category-title">', '</h1>' );
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
// Category description
$category_description = category_description();
if ( ! empty( $category_description ) ) :
?>
<div class="archive-description category-description">
<?php echo wp_kses_post( wpautop( $category_description ) ); ?>
</div>
<?php endif; ?>
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<!-- Category metadata -->
<?php
$category = get_queried_object();
if ( $category ) :
?>
<div class="category-meta">
<span class="category-count">
<?php
printf(
/* translators: %s: number of posts */
esc_html( _n( '%s post', '%s posts', $category->count, 'roi-theme' ) ),
esc_html( number_format_i18n( $category->count ) )
);
?>
</span>
</div>
<?php endif; ?>
</header><!-- .page-header -->
<!-- Social Share - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('social-share');
}
?>
<!-- Category Posts Loop -->
<div class="archive-posts category-posts">
<!-- CTA Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-post');
}
?>
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
<!-- Related Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('related-post');
}
?>
/**
* Include the Post-Type-specific template for the content.
* If you want to override this in a child theme, then include a file
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
*/
get_template_part( 'template-parts/content', get_post_type() );
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
endwhile;
?>
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
</div><!-- .archive-posts -->
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
<?php
/**
* Pagination
* Display navigation to next/previous set of posts when applicable.
*/
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => sprintf(
'%s <span class="nav-prev-text">%s</span>',
'<span class="nav-icon" aria-hidden="true">&laquo;</span>',
esc_html__( 'Previous', 'roi-theme' )
),
'next_text' => sprintf(
'<span class="nav-next-text">%s</span> %s',
esc_html__( 'Next', 'roi-theme' ),
'<span class="nav-icon" aria-hidden="true">&raquo;</span>'
),
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
)
);
</div><!-- .row -->
</div><!-- .container -->
else :
</main><!-- #main-content -->
/**
* No posts found
* Display a message when no content is available.
*/
get_template_part( 'template-parts/content', 'none' );
endif;
?>
</div><!-- #primary -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
?>
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

164
date.php
View File

@@ -2,8 +2,9 @@
/**
* The template for displaying date-based archive pages
*
* This template displays posts from a specific date (year, month, or day)
* with the date information displayed at the top.
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#date
*
@@ -14,115 +15,78 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Archive Header - Componente dinamico (detecta fecha automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<?php if ( have_posts() ) : ?>
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Date Archive Header -->
<header class="page-header date-header">
<?php
// Date archive title
the_archive_title( '<h1 class="page-title date-title">', '</h1>' );
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
// Date archive description
$date_description = get_the_archive_description();
if ( ! empty( $date_description ) ) :
?>
<div class="archive-description date-description">
<?php echo wp_kses_post( wpautop( $date_description ) ); ?>
</div>
<?php endif; ?>
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<!-- Date metadata -->
<div class="date-meta">
<span class="posts-count">
<?php
global $wp_query;
$found_posts = $wp_query->found_posts;
printf(
/* translators: %s: number of posts */
esc_html( _n( '%s post', '%s posts', $found_posts, 'roi-theme' ) ),
esc_html( number_format_i18n( $found_posts ) )
);
?>
</span>
</div>
</header><!-- .page-header -->
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<!-- Date Posts Loop -->
<div class="archive-posts date-posts">
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
/**
* Include the Post-Type-specific template for the content.
* If you want to override this in a child theme, then include a file
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
*/
get_template_part( 'template-parts/content', get_post_type() );
</div><!-- .row -->
</div><!-- .container -->
endwhile;
?>
</main><!-- #main-content -->
</div><!-- .archive-posts -->
<?php
/**
* Pagination
* Display navigation to next/previous set of posts when applicable.
*/
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => sprintf(
'%s <span class="nav-prev-text">%s</span>',
'<span class="nav-icon" aria-hidden="true">&laquo;</span>',
esc_html__( 'Previous', 'roi-theme' )
),
'next_text' => sprintf(
'<span class="nav-next-text">%s</span> %s',
esc_html__( 'Next', 'roi-theme' ),
'<span class="nav-icon" aria-hidden="true">&raquo;</span>'
),
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
)
);
else :
/**
* No posts found
* Display a message when no content is available.
*/
get_template_part( 'template-parts/content', 'none' );
endif;
?>
</div><!-- #primary -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
?>
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

View File

@@ -207,8 +207,17 @@ function roi_render_component(string $componentName): string {
// Decodificar valor
$value = $row->attribute_value;
// Convertir booleanos almacenados como '1' o '0'
if ($value === '1' || $value === '0') {
// Solo convertir a booleano campos que realmente son booleanos
// Los grupos 'visibility', '_page_visibility' y campos que empiezan con 'show_', 'is_', 'enable'
$isBooleanField = (
$row->group_name === 'visibility' ||
$row->group_name === '_page_visibility' ||
str_starts_with($row->attribute_name, 'show_') ||
str_starts_with($row->attribute_name, 'is_') ||
str_starts_with($row->attribute_name, 'enable')
);
if ($isBooleanField && ($value === '1' || $value === '0')) {
$value = ($value === '1');
} else {
// Intentar decodificar JSON
@@ -321,6 +330,12 @@ function roi_render_component(string $componentName): string {
case 'footer':
$renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator);
break;
case 'archive-header':
$renderer = new \ROITheme\Public\ArchiveHeader\Infrastructure\Ui\ArchiveHeaderRenderer($cssGenerator);
break;
case 'post-grid':
$renderer = new \ROITheme\Public\PostGrid\Infrastructure\Ui\PostGridRenderer($cssGenerator);
break;
}
if (!$renderer) {
@@ -547,3 +562,22 @@ function roi_get_adsense_search_config(): array {
],
];
}
// =============================================================================
// POST GRID SHORTCODE [roi_post_grid]
// =============================================================================
/**
* Registra el shortcode [roi_post_grid] para mostrar grids de posts
* en cualquier pagina o entrada.
*
* USO:
* [roi_post_grid]
* [roi_post_grid category="precios-unitarios" posts_per_page="6"]
* [roi_post_grid id="grid-1" category="cursos" show_pagination="true"]
*
* @see Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar
*/
add_action('init', function() {
\ROITheme\Shared\Infrastructure\Wordpress\PostGridShortcodeRegistrar::register();
});

View File

@@ -50,7 +50,7 @@ require_once get_template_directory() . '/Inc/featured-image.php';
require_once get_template_directory() . '/Inc/category-badge.php';
require_once get_template_directory() . '/Inc/adsense-delay.php';
require_once get_template_directory() . '/Inc/adsense-placement.php';
require_once get_template_directory() . '/Inc/related-posts.php';
// ELIMINADO: Inc/related-posts.php (Plan 101 - usa RelatedPostRenderer)
// ELIMINADO: Inc/toc.php (FASE 6 - Clean Architecture: usa TableOfContentsRenderer)
require_once get_template_directory() . '/Inc/apu-tables.php';
require_once get_template_directory() . '/Inc/search-disable.php';
@@ -174,6 +174,12 @@ try {
);
$youtubeFacadeHooksRegistrar->register();
// === CACHE-FIRST ARCHITECTURE (Plan 1000.01) ===
// Hook para plugins externos que necesitan evaluar acceso antes de servir página
// @see openspec/specs/cache-first-architecture/spec.md
$cacheFirstHooksRegistrar = new \ROITheme\Shared\Infrastructure\Hooks\CacheFirstHooksRegistrar();
$cacheFirstHooksRegistrar->register();
// Log en modo debug
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('ROI Theme: Admin Panel initialized successfully');
@@ -329,6 +335,21 @@ if (!is_admin()) {
}
}
// =============================================================================
// 5.2.1. CUSTOM CSS MANAGER BOOTSTRAP (Handler de formulario POST)
// =============================================================================
/**
* Inicializar Bootstrap de CustomCSSManager para admin
*
* IMPORTANTE: Este Bootstrap registra el handler de formulario POST en admin_init,
* ANTES de que WordPress envíe headers HTTP. Esto permite que wp_redirect()
* funcione correctamente después de guardar/eliminar snippets.
*/
if (is_admin()) {
\ROITheme\Admin\CustomCSSManager\Infrastructure\Bootstrap\CustomCSSManagerBootstrap::init();
}
// =============================================================================
// 5.3. INFORMACIÓN DE DEBUG (Solo en desarrollo)
// =============================================================================

159
home.php
View File

@@ -2,9 +2,9 @@
/**
* The template for displaying the blog posts index
*
* This template is used when the blog posts page is different from the front page.
* It displays a listing of recent blog posts with pagination.
* Set in WordPress Settings > Reading > "Posts page".
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#home
*
@@ -15,109 +15,78 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Archive Header - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<?php if ( have_posts() ) : ?>
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Blog Header -->
<header class="page-header">
<?php
// Display blog page title
if ( is_home() && ! is_front_page() && get_option( 'page_for_posts' ) ) :
?>
<h1 class="page-title">
<?php echo esc_html( get_the_title( get_option( 'page_for_posts' ) ) ); ?>
</h1>
<?php
// Display blog page description if available
$blog_page = get_post( get_option( 'page_for_posts' ) );
if ( $blog_page && ! empty( $blog_page->post_content ) ) :
?>
<div class="page-description">
<?php echo wp_kses_post( wpautop( $blog_page->post_excerpt ) ); ?>
</div>
<?php endif; ?>
<?php else : ?>
<h1 class="page-title">
<?php esc_html_e( 'Blog', 'roi-theme' ); ?>
</h1>
<?php endif; ?>
</header><!-- .page-header -->
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Blog Posts Loop -->
<div class="blog-posts">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
/**
* Include the Post-Type-specific template for the content.
* If you want to override this in a child theme, then include a file
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
*/
get_template_part( 'template-parts/content', get_post_type() );
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
endwhile;
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
</div><!-- .blog-posts -->
</div><!-- .row -->
</div><!-- .container -->
<?php
/**
* Pagination
* Display navigation to next/previous set of posts when applicable.
*/
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => sprintf(
'%s <span class="nav-prev-text">%s</span>',
'<span class="nav-icon" aria-hidden="true">&laquo;</span>',
esc_html__( 'Previous', 'roi-theme' )
),
'next_text' => sprintf(
'<span class="nav-next-text">%s</span> %s',
esc_html__( 'Next', 'roi-theme' ),
'<span class="nav-icon" aria-hidden="true">&raquo;</span>'
),
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
)
);
</main><!-- #main-content -->
else :
/**
* No posts found
* Display a message when no content is available.
*/
get_template_part( 'template-parts/content', 'none' );
endif;
?>
</div><!-- #primary -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
?>
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

View File

@@ -0,0 +1,154 @@
# Especificacion de Arquitectura Cache-First
## Purpose
Define la arquitectura cache-first para ROITheme que permite que plugins de cache (W3TC, Redis, etc.) funcionen correctamente mientras plugins externos pueden evaluar condiciones ANTES de servir paginas.
Esta arquitectura:
- Permite que las paginas se sirvan desde cache siempre que sea posible
- Provee hooks para que plugins externos (rate limiters, access control, etc.) evaluen condiciones
- Desacopla el tema de plugins especificos de restriccion de acceso
- Es portable: cualquier sitio con roi-theme tiene esta capacidad
## Requirements
### Requirement: Hook de Pre-Evaluacion de Pagina
The system MUST provide a hook that fires BEFORE WordPress serves a page, allowing external plugins to evaluate conditions and potentially redirect.
#### Scenario: Plugin externo necesita evaluar acceso antes de servir pagina
- **GIVEN** un plugin de control de acceso (rate limiter, membership, etc.)
- **WHEN** un visitante solicita una pagina singular (post, page, CPT)
- **THEN** el tema DEBE disparar `do_action('roi_theme_before_page_serve', $post_id)`
- **AND** el hook DEBE ejecutarse en `template_redirect` con priority 0
- **AND** si el plugin llama `wp_safe_redirect()` y `exit`, la pagina NO se sirve
#### Scenario: Ningn plugin enganchado al hook
- **GIVEN** ningun plugin esta escuchando `roi_theme_before_page_serve`
- **WHEN** un visitante solicita una pagina
- **THEN** la pagina se sirve normalmente (con cache si disponible)
- **AND** no hay impacto en rendimiento
#### Scenario: Hook solo dispara en paginas singulares
- **GIVEN** el hook `roi_theme_before_page_serve`
- **WHEN** la solicitud es para archivo, home, search, feed, o admin
- **THEN** el hook NO DEBE dispararse
- **AND** la pagina se sirve sin evaluacion adicional
#### Scenario: Hook no dispara para usuarios logueados
- **GIVEN** un usuario autenticado (logged in)
- **WHEN** solicita cualquier pagina
- **THEN** el hook NO DEBE dispararse
- **AND** la pagina se sirve directamente sin evaluacion
- **BECAUSE** WordPress no cachea paginas para usuarios logueados (cookies de sesion)
---
### Requirement: Contexto Rico para Plugins Externos
The system MUST provide sufficient context for external plugins to make access decisions.
#### Scenario: Plugin necesita informacion del post
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
- **WHEN** el hook se dispara
- **THEN** el plugin recibe `$post_id` como parametro
- **AND** `get_queried_object()` retorna el objeto WP_Post completo
- **AND** `is_singular()`, `is_single()`, `is_page()` funcionan correctamente
#### Scenario: Plugin necesita informacion del visitante
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
- **WHEN** el hook se dispara
- **THEN** `is_user_logged_in()` esta disponible
- **AND** `$_SERVER['REMOTE_ADDR']` esta disponible
- **AND** headers HTTP estan disponibles via `$_SERVER`
---
### Requirement: No Bloquear Cache
The system MUST NOT define DONOTCACHEPAGE or similar constants that prevent caching.
#### Scenario: Tema no interfiere con plugins de cache
- **GIVEN** el tema roi-theme instalado
- **WHEN** W3TC, WP Super Cache, o Redis Object Cache estan activos
- **THEN** el tema NO DEBE definir `DONOTCACHEPAGE`
- **AND** el tema NO DEBE definir `DONOTCACHEOBJECT`
- **AND** el tema NO DEBE enviar headers `Cache-Control: no-cache`
#### Scenario: Plugin externo decide bloquear cache
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
- **WHEN** el plugin necesita bloquear cache para una pagina especifica
- **THEN** es responsabilidad del PLUGIN definir `DONOTCACHEPAGE`
- **AND** el tema NO participa en esa decision
---
### Requirement: Ubicacion en Clean Architecture
The hook registration MUST follow ROITheme's Clean Architecture patterns.
#### Scenario: Hook registrado en Infrastructure
- **GIVEN** el sistema de hooks del tema
- **WHEN** el hook `roi_theme_before_page_serve` es registrado
- **THEN** el registro DEBE estar en `Shared/Infrastructure/Hooks/`
- **AND** DEBE seguir el patron de HooksRegistrar existente
#### Scenario: Servicio como punto de extension
- **GIVEN** la arquitectura del tema
- **WHEN** se necesita extender funcionalidad de pre-evaluacion
- **THEN** DEBE existir una interfaz en `Shared/Domain/Contracts/`
- **AND** la implementacion DEBE estar en `Shared/Infrastructure/Services/`
---
## Implementation Notes
### Hook Priority
```
template_redirect priority order:
├─ Priority 0: roi_theme_before_page_serve (tema dispara hook)
│ └─ Plugins externos se enganchan aqui
├─ Priority 1+: Otros plugins
└─ Priority 10 (default): WordPress template loading
```
### Ejemplo de Plugin Enganchado
```php
// En un plugin externo (ej: ip-rate-limiter)
add_action('roi_theme_before_page_serve', function(int $post_id) {
// Evaluar condicion (ej: limite de IP)
if ($this->is_limit_exceeded()) {
wp_safe_redirect('/suscripcion-vip/?reason=limit', 302);
exit;
}
// Si no hay problema, simplemente retornar
// La pagina se servira (con cache si disponible)
}, 10);
```
### Archivos a Crear
```
Shared/
├── Domain/
│ └── Contracts/
│ └── PageServeHookInterface.php # Interface del hook
└── Infrastructure/
└── Hooks/
└── CacheFirstHooksRegistrar.php # Registro del hook
```
---
## Acceptance Criteria
1. El hook `roi_theme_before_page_serve` se dispara en `template_redirect` priority 0
2. Solo se dispara para `is_singular() === true`
3. NO se dispara para usuarios logueados (`is_user_logged_in() === true`)
4. Pasa `$post_id` como parametro al hook
5. No define DONOTCACHEPAGE ni headers anti-cache
6. Plugins externos pueden enganchar y hacer redirect/exit
7. Si ningun plugin engancha, no hay impacto en rendimiento
8. Sigue patrones de Clean Architecture del tema

View File

@@ -0,0 +1,410 @@
# Especificacion de Shortcode Post Grid
## Purpose
Crear un shortcode `[roi_post_grid]` que permita insertar grids de posts en cualquier pagina o entrada de WordPress, con filtros configurables por categoria, tag, autor y otras opciones. Sigue Clean Architecture delegando logica a Use Cases.
## Context
### Problema Actual
El componente `post-grid` implementado en `templates-unificados` solo funciona en templates de archivo (home.php, archive.php, etc.) porque depende del loop principal de WordPress (`global $wp_query`).
### Solucion
Crear un shortcode que:
1. Siga Clean Architecture (ShortcodeRegistrar → UseCase → Repository)
2. Reutilice estilos del componente `post-grid` existente
3. Permita filtrar posts por categoria, tag, autor
4. No dependa del loop principal de WordPress
### Relacion con templates-unificados
- Este shortcode **complementa** (no reemplaza) el componente post-grid
- El componente post-grid sigue funcionando en templates de archivo
- El shortcode permite insertar grids en contenido arbitrario
### Nota sobre shortcodes legacy
Los shortcodes existentes (`apu_table`, `apu_row`) estan en `Inc/apu-tables.php` (patron legacy). Este nuevo shortcode sigue el patron moderno de Clean Architecture.
---
## Requirements
### Requirement: Arquitectura del Shortcode
The shortcode MUST follow Clean Architecture patterns.
#### Scenario: Ubicacion del ShortcodeRegistrar
- **WHEN** se implementa el shortcode
- **THEN** DEBE estar en `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Wordpress`
- **AND** DEBE registrar el shortcode via add_shortcode
#### Scenario: Delegacion a Use Case
- **WHEN** el shortcode se ejecuta
- **THEN** PostGridShortcodeRegistrar DEBE delegar a RenderPostGridUseCase
- **AND** NO DEBE contener logica de negocio
- **AND** solo sanitiza atributos y pasa al Use Case
#### Scenario: Ubicacion del Use Case
- **WHEN** se implementa la logica del shortcode
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Application\UseCases\RenderPostGrid`
- **AND** DEBE orquestar Query, Renderer y Settings
#### Scenario: Ubicacion del QueryBuilder
- **WHEN** se construye el WP_Query
- **THEN** DEBE estar en `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Query`
- **AND** DEBE recibir parametros y retornar WP_Query
#### Scenario: Ubicacion del ShortcodeRenderer
- **WHEN** se genera el HTML del grid
- **THEN** DEBE estar en `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Ui`
- **AND** DEBE inyectar CSSGeneratorInterface
---
### Requirement: Estructura de Clases
Each class MUST have single responsibility following SRP.
#### Scenario: Responsabilidad de PostGridShortcodeRegistrar
- **WHEN** se define PostGridShortcodeRegistrar
- **THEN** DEBE tener metodo estatico `register()` para add_shortcode
- **AND** DEBE tener metodo `handleShortcode(array $atts): string`
- **AND** handleShortcode DEBE sanitizar atributos
- **AND** handleShortcode DEBE obtener UseCase del DIContainer
- **AND** handleShortcode DEBE retornar resultado del UseCase
- **AND** NO DEBE tener mas de 50 lineas
#### Scenario: Responsabilidad de RenderPostGridUseCase
- **WHEN** se define RenderPostGridUseCase
- **THEN** DEBE recibir RenderPostGridRequest como input
- **AND** DEBE inyectar PostGridQueryBuilderInterface via constructor
- **AND** DEBE inyectar PostGridShortcodeRendererInterface via constructor
- **AND** DEBE inyectar ComponentSettingsRepositoryInterface via constructor
- **AND** DEBE orquestar: obtener settings, construir query, renderizar
- **AND** DEBE retornar string HTML
- **AND** NO DEBE tener mas de 80 lineas
#### Scenario: Responsabilidad de PostGridQueryBuilder
- **WHEN** se define PostGridQueryBuilder
- **THEN** DEBE recibir parametros de filtro (category, tag, etc.)
- **AND** DEBE construir array de argumentos WP_Query
- **AND** DEBE retornar WP_Query configurado
- **AND** NO DEBE generar HTML
- **AND** NO DEBE tener mas de 100 lineas
#### Scenario: Responsabilidad de PostGridShortcodeRenderer
- **WHEN** se define PostGridShortcodeRenderer
- **THEN** DEBE recibir WP_Query y configuracion
- **AND** DEBE inyectar CSSGeneratorInterface
- **AND** DEBE generar HTML del grid
- **AND** DEBE generar CSS inline
- **AND** NO DEBE construir queries
- **AND** NO DEBE tener mas de 150 lineas
---
### Requirement: Interfaces en Domain/Application
Interfaces MUST be defined for dependency injection.
#### Scenario: Interface del QueryBuilder
- **WHEN** se define la interface
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
- **AND** DEBE tener metodo `build(array $params): \WP_Query`
#### Scenario: Interface del Renderer
- **WHEN** se define la interface
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
- **AND** DEBE tener metodo `render(\WP_Query $query, array $settings, array $options): string`
#### Scenario: Request DTO
- **WHEN** se define el DTO de entrada
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
- **AND** DEBE ser readonly class con propiedades tipadas
- **AND** DEBE contener todos los atributos del shortcode
---
### Requirement: Atributos del Shortcode
The shortcode MUST accept configurable attributes.
#### Scenario: Atributo category
- **WHEN** se usa `[roi_post_grid category="precios-unitarios"]`
- **THEN** DEBE filtrar posts por slug de categoria
- **AND** DEBE aceptar multiples categorias separadas por coma
- **AND** ejemplo: `category="cat1,cat2,cat3"`
#### Scenario: Atributo exclude_category
- **WHEN** se usa `[roi_post_grid exclude_category="noticias"]`
- **THEN** DEBE excluir posts de esas categorias
- **AND** DEBE aceptar multiples categorias separadas por coma
#### Scenario: Atributo tag
- **WHEN** se usa `[roi_post_grid tag="concreto"]`
- **THEN** DEBE filtrar posts por slug de tag
- **AND** DEBE aceptar multiples tags separados por coma
#### Scenario: Atributo author
- **WHEN** se usa `[roi_post_grid author="admin"]`
- **THEN** DEBE filtrar posts por login de autor
- **AND** DEBE aceptar ID numerico o login string
#### Scenario: Atributo posts_per_page
- **WHEN** se usa `[roi_post_grid posts_per_page="6"]`
- **THEN** DEBE limitar cantidad de posts mostrados
- **AND** default DEBE ser 9
- **AND** DEBE aceptar valores entre 1 y 50
#### Scenario: Atributo columns
- **WHEN** se usa `[roi_post_grid columns="4"]`
- **THEN** DEBE definir columnas en desktop
- **AND** default DEBE ser 3
- **AND** DEBE aceptar valores 1, 2, 3 o 4
#### Scenario: Atributo orderby
- **WHEN** se usa `[roi_post_grid orderby="title"]`
- **THEN** DEBE ordenar posts por ese campo
- **AND** default DEBE ser "date"
- **AND** opciones validas: date, title, modified, rand, comment_count
#### Scenario: Atributo order
- **WHEN** se usa `[roi_post_grid order="ASC"]`
- **THEN** DEBE definir direccion del orden
- **AND** default DEBE ser "DESC"
- **AND** opciones validas: ASC, DESC
#### Scenario: Atributo show_pagination
- **WHEN** se usa `[roi_post_grid show_pagination="true"]`
- **THEN** DEBE mostrar paginacion si hay mas posts
- **AND** default DEBE ser false
#### Scenario: Atributo offset
- **WHEN** se usa `[roi_post_grid offset="3"]`
- **THEN** DEBE saltar los primeros N posts
- **AND** default DEBE ser 0
#### Scenario: Atributo exclude_posts
- **WHEN** se usa `[roi_post_grid exclude_posts="123,456"]`
- **THEN** DEBE excluir posts por ID
- **AND** DEBE aceptar IDs separados por coma
#### Scenario: Atributos de visualizacion
- **WHEN** se usan atributos de visualizacion
- **THEN** show_thumbnail default true
- **AND** show_excerpt default true
- **AND** show_meta default true
- **AND** show_categories default true
- **AND** excerpt_length default 20
#### Scenario: Atributo class
- **WHEN** se usa `[roi_post_grid class="my-custom-grid"]`
- **THEN** DEBE agregar clase CSS adicional al contenedor
#### Scenario: Atributo id para paginacion multiple
- **WHEN** se usa `[roi_post_grid id="grid-1" show_pagination="true"]`
- **THEN** DEBE usar query var unico `paged_grid-1`
- **AND** permite multiples shortcodes paginados en misma pagina
---
### Requirement: Obtencion de Settings
The shortcode MUST obtain styles from post-grid component settings.
#### Scenario: Lectura de configuracion
- **WHEN** RenderPostGridUseCase se ejecuta
- **THEN** DEBE usar ComponentSettingsRepositoryInterface
- **AND** DEBE obtener settings de componente 'post-grid'
- **AND** DEBE aplicar colores, spacing, visual_effects del componente
#### Scenario: Settings no encontrados
- **WHEN** no existen settings de post-grid en BD
- **THEN** DEBE usar valores default definidos en VisibilityDefaults
- **AND** NO DEBE fallar con error
---
### Requirement: Renderizado HTML
The shortcode MUST generate valid HTML with proper escaping.
#### Scenario: Estructura HTML del grid
- **WHEN** el shortcode renderiza
- **THEN** DEBE generar contenedor con clase `roi-post-grid-shortcode`
- **AND** DEBE generar row con clase Bootstrap `row`
- **AND** cada card DEBE tener columna responsive
#### Scenario: Clases responsive de columnas
- **WHEN** columns es 3
- **THEN** cada card DEBE tener `col-12 col-md-6 col-lg-4`
- **AND** para columns=4: `col-12 col-md-6 col-lg-3`
- **AND** para columns=2: `col-12 col-md-6`
- **AND** para columns=1: `col-12`
#### Scenario: Sin resultados
- **WHEN** el query no encuentra posts
- **THEN** DEBE mostrar mensaje "No se encontraron publicaciones"
- **AND** NO DEBE romper layout
#### Scenario: Escaping obligatorio
- **WHEN** se genera HTML
- **THEN** DEBE usar esc_html() para textos
- **AND** DEBE usar esc_attr() para atributos
- **AND** DEBE usar esc_url() para URLs
- **AND** DEBE usar wp_kses_post() para excerpts
---
### Requirement: CSS via CSSGenerator
The shortcode MUST use CSSGeneratorInterface for styles.
#### Scenario: Generacion de CSS
- **WHEN** PostGridShortcodeRenderer genera CSS
- **THEN** DEBE inyectar CSSGeneratorInterface
- **AND** DEBE usar settings de post-grid desde BD
- **AND** DEBE generar CSS inline en el shortcode
#### Scenario: Selector unico
- **WHEN** se genera CSS
- **THEN** DEBE usar selector `.roi-post-grid-shortcode`
- **AND** si se especifica id, usar `.roi-post-grid-shortcode-{id}`
- **AND** NO DEBE conflictuar con post-grid del template
---
### Requirement: Registro del Shortcode
The shortcode MUST be registered in WordPress.
#### Scenario: Registro en bootstrap
- **WHEN** WordPress carga el tema
- **THEN** functions-addon.php DEBE llamar PostGridShortcodeRegistrar::register()
- **AND** DEBE estar disponible en editor clasico y Gutenberg
#### Scenario: Metodo register estatico
- **WHEN** se llama PostGridShortcodeRegistrar::register()
- **THEN** DEBE ejecutar add_shortcode('roi_post_grid', ...)
- **AND** DEBE usar DIContainer para obtener dependencias
---
## Implementation Order
### Fase 1: Interfaces y DTOs
1. Crear `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
2. Crear `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
3. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
### Fase 2: Use Case
1. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
2. Implementar orquestacion de query, settings, render
### Fase 3: Infrastructure - Query
1. Crear `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
2. Implementar construccion de WP_Query con todos los filtros
### Fase 4: Infrastructure - Renderer
1. Crear `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
2. Implementar generacion HTML y CSS
### Fase 5: Infrastructure - Registrar
1. Crear `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
2. Implementar registro y sanitizacion de atributos
### Fase 6: Registro y DI
1. Registrar en DIContainer las implementaciones
2. Llamar register() en functions-addon.php
### Fase 7: Testing
1. Probar en pagina estatica
2. Verificar filtros por categoria, tag
3. Verificar paginacion con id unico
---
## File Structure
```
Shared/
├── Domain/
│ └── Contracts/
│ ├── PostGridQueryBuilderInterface.php
│ └── PostGridShortcodeRendererInterface.php
├── Application/
│ └── UseCases/
│ └── RenderPostGrid/
│ ├── RenderPostGridRequest.php
│ └── RenderPostGridUseCase.php
└── Infrastructure/
├── Query/
│ └── PostGridQueryBuilder.php
├── Ui/
│ └── PostGridShortcodeRenderer.php
└── Wordpress/
└── PostGridShortcodeRegistrar.php
```
---
## Examples
### Ejemplo: Grid basico
```
[roi_post_grid]
```
### Ejemplo: Posts de una categoria
```
[roi_post_grid category="precios-unitarios" posts_per_page="6"]
```
### Ejemplo: Multiples shortcodes con paginacion
```
[roi_post_grid id="grid-cursos" category="cursos" show_pagination="true"]
[roi_post_grid id="grid-tutoriales" category="tutoriales" show_pagination="true"]
```
---
## Dependencies
### Existentes (reutilizar)
- `CSSGeneratorInterface` - Para generar CSS
- `ComponentSettingsRepositoryInterface` - Para leer config de post-grid
- `DIContainer` - Para inyeccion de dependencias
- Bootstrap 5 grid system - Para layout responsive
### Nuevas (crear)
- `PostGridQueryBuilderInterface` - Contrato para query builder
- `PostGridShortcodeRendererInterface` - Contrato para renderer
- `RenderPostGridRequest` - DTO de entrada
- `RenderPostGridUseCase` - Orquestador
- `PostGridQueryBuilder` - Implementacion query
- `PostGridShortcodeRenderer` - Implementacion render
- `PostGridShortcodeRegistrar` - Registro WordPress
---
## Testing Checklist
- [ ] Shortcode renderiza sin atributos
- [ ] Filtro por categoria funciona
- [ ] Filtro por multiples categorias funciona
- [ ] Exclusion de categoria funciona
- [ ] Filtro por tag funciona
- [ ] Columnas 1, 2, 3, 4 funcionan
- [ ] Paginacion con id unico funciona
- [ ] Multiples shortcodes paginados funcionan
- [ ] CSS se aplica desde settings de post-grid
- [ ] Mensaje "sin posts" aparece cuando corresponde
- [ ] Escaping correcto en todo el HTML
- [ ] Funciona en editor clasico
- [ ] Funciona en Gutenberg
- [ ] No hay errores PHP
- [ ] Clases tienen menos de 150 lineas

View File

@@ -0,0 +1,417 @@
# Especificacion de Templates Unificados para Blog/Archive
## Purpose
Define la arquitectura para unificar todos los templates de listados (blog, categorias, tags, archives) usando la misma estructura que `single.php`, aprovechando el sistema de visibilidad existente para controlar que componentes mostrar en cada contexto. Incluye la creacion de dos nuevos componentes: `archive-header` y `post-grid`.
## Requirements
### Requirement: Template Unificado para Listados
All listing templates MUST use the same structure as single.php.
#### Scenario: Estructura base de templates de listado
- **WHEN** se implementa home.php, archive.php, category.php o tag.php
- **THEN** DEBE usar la misma estructura que single.php
- **AND** DEBE llamar a roi_render_component() para cada componente
- **AND** la visibilidad se controla via PageVisibilityHelper::shouldShow()
#### Scenario: Componentes que se llaman en templates de listado
- **WHEN** se renderiza un template de listado
- **THEN** DEBE llamar a roi_render_component('hero')
- **AND** DEBE llamar a roi_render_component('archive-header')
- **AND** DEBE llamar a roi_render_component('post-grid')
- **AND** DEBE llamar a roi_render_component('table-of-contents') en sidebar
- **AND** DEBE llamar a roi_render_component('cta-box-sidebar') en sidebar
- **AND** DEBE llamar a roi_render_component('contact-form')
- **AND** cada componente decide si renderiza segun show_on_archives
#### Scenario: Determinacion de sidebar en listados
- **WHEN** se determina si mostrar sidebar en un listado
- **THEN** DEBE usar roi_should_render_any_wrapper(['table-of-contents', 'cta-box-sidebar'])
- **AND** si retorna true usar col-lg-9 para contenido principal
- **AND** si retorna false usar col-lg-12 para contenido principal
#### Scenario: Paginacion en templates de listado
- **WHEN** se muestra paginacion en un listado
- **THEN** DEBE usar the_posts_pagination() de WordPress
- **AND** DEBE aplicar estilos Bootstrap via CSS del componente post-grid
#### Scenario: CSS de paginacion generado por post-grid
- **WHEN** PostGridRenderer renderiza la paginacion
- **THEN** el CSS de paginacion DEBE generarse via CSSGeneratorService
- **AND** DEBE aplicar estilos Bootstrap (nav-links, page-numbers)
- **AND** los colores DEBEN ser configurables via grupo colors del schema
---
### Requirement: Componente archive-header
The archive-header component MUST display dynamic title and description for archive pages.
#### Scenario: Ubicacion de archivos archive-header
- **WHEN** se crea el componente archive-header
- **THEN** schema DEBE estar en Schemas/archive-header.json
- **AND** Renderer DEBE estar en Public/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderRenderer.php
- **AND** FormBuilder DEBE estar en Admin/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderFormBuilder.php
- **AND** FieldMapper DEBE estar en Admin/ArchiveHeader/Infrastructure/FieldMapping/ArchiveHeaderFieldMapper.php
#### Scenario: Namespaces PHP de archive-header
- **WHEN** se definen los namespaces para archive-header
- **THEN** Renderer DEBE usar namespace `ROITheme\Public\ArchiveHeader\Infrastructure\Ui`
- **AND** FormBuilder DEBE usar namespace `ROITheme\Admin\ArchiveHeader\Infrastructure\Ui`
- **AND** FieldMapper DEBE usar namespace `ROITheme\Admin\ArchiveHeader\Infrastructure\FieldMapping`
#### Scenario: Deteccion automatica de tipo de archivo
- **WHEN** ArchiveHeaderRenderer detecta el tipo de pagina
- **THEN** para categoria DEBE mostrar "Categoria: [nombre]"
- **AND** para tag DEBE mostrar "Etiqueta: [nombre]"
- **AND** para autor DEBE mostrar "Articulos de: [nombre]"
- **AND** para fecha DEBE mostrar "Archivo: [Mes Ano]"
- **AND** para busqueda DEBE mostrar "Resultados para: [termino]"
- **AND** para blog home DEBE mostrar el valor de blog_title del schema
#### Scenario: Grupos del schema archive-header
- **WHEN** se define el schema archive-header.json
- **THEN** DEBE incluir grupo visibility con priority 10
- **AND** DEBE incluir grupo content con priority 20
- **AND** DEBE incluir grupo typography con priority 30
- **AND** DEBE incluir grupo colors con priority 40
- **AND** DEBE incluir grupo spacing con priority 50
- **AND** DEBE incluir grupo behavior con priority 70
- **NOTE** archive-header NO incluye visual_effects (priority 60) porque es un componente de texto simple sin sombras, bordes redondeados ni transiciones hover
#### Scenario: Campos obligatorios de visibility en archive-header
- **WHEN** se define grupo visibility en schema
- **THEN** DEBE incluir is_enabled como boolean con default true
- **AND** DEBE incluir show_on_desktop como boolean con default true
- **AND** DEBE incluir show_on_mobile como boolean con default true
#### Scenario: Campos de _page_visibility en archive-header
- **WHEN** se configura visibilidad por tipo de pagina en FieldMapper
- **THEN** DEBE mapear campo show_on_home en grupo _page_visibility con default false
- **AND** DEBE mapear campo show_on_posts en grupo _page_visibility con default false
- **AND** DEBE mapear campo show_on_pages en grupo _page_visibility con default false
- **AND** DEBE mapear campo show_on_archives en grupo _page_visibility con default true
- **AND** DEBE mapear campo show_on_search en grupo _page_visibility con default false
- **NOTE** Los campos _page_visibility NO van en el schema JSON, se manejan via FieldMapper y VisibilityDefaults
- **NOTE** show_on_archives en true porque este componente solo tiene sentido en archives
#### Scenario: Campos de content en archive-header
- **WHEN** se define grupo content
- **THEN** DEBE incluir blog_title como text con default "Blog"
- **AND** DEBE incluir show_post_count como boolean con default true
- **AND** DEBE incluir show_description como boolean con default true
#### Scenario: Campos de typography en archive-header
- **WHEN** se define grupo typography
- **THEN** DEBE incluir heading_level como select con options ["h1", "h2", "h3", "h4", "h5", "h6"] y default "h1"
- **AND** DEBE incluir title_size como text con default "2rem"
- **AND** DEBE incluir title_weight como text con default "700"
- **AND** DEBE incluir description_size como text con default "1rem"
#### Scenario: Campos de colors en archive-header
- **WHEN** se define grupo colors
- **THEN** DEBE incluir title_color como color con default "#0E2337"
- **AND** DEBE incluir description_color como color con default "#6b7280"
- **AND** DEBE incluir count_bg_color como color con default "#FF8600"
- **AND** DEBE incluir count_text_color como color con default "#ffffff"
#### Scenario: Campos de spacing en archive-header
- **WHEN** se define grupo spacing
- **THEN** DEBE incluir margin_top como text con default "2rem"
- **AND** DEBE incluir margin_bottom como text con default "2rem"
- **AND** DEBE incluir padding como text con default "1.5rem"
#### Scenario: Campos de behavior en archive-header
- **WHEN** se define grupo behavior
- **THEN** DEBE incluir is_sticky como boolean con default false
- **AND** DEBE incluir sticky_offset como text con default "0"
---
### Requirement: Componente post-grid
The post-grid component MUST display posts from the main WordPress loop in a grid layout.
#### Scenario: Ubicacion de archivos post-grid
- **WHEN** se crea el componente post-grid
- **THEN** schema DEBE estar en Schemas/post-grid.json
- **AND** Renderer DEBE estar en Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
- **AND** FormBuilder DEBE estar en Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php
- **AND** FieldMapper DEBE estar en Admin/PostGrid/Infrastructure/FieldMapping/PostGridFieldMapper.php
#### Scenario: Namespaces PHP de post-grid
- **WHEN** se definen los namespaces para post-grid
- **THEN** Renderer DEBE usar namespace `ROITheme\Public\PostGrid\Infrastructure\Ui`
- **AND** FormBuilder DEBE usar namespace `ROITheme\Admin\PostGrid\Infrastructure\Ui`
- **AND** FieldMapper DEBE usar namespace `ROITheme\Admin\PostGrid\Infrastructure\FieldMapping`
#### Scenario: Diferencia entre post-grid y related-post
- **WHEN** PostGridRenderer obtiene los posts
- **THEN** DEBE usar global $wp_query para obtener posts del loop principal
- **AND** NO DEBE crear su propio WP_Query como hace RelatedPostRenderer
- **AND** DEBE llamar wp_reset_postdata() al finalizar si modifica el loop
#### Scenario: Grupos del schema post-grid
- **WHEN** se define el schema post-grid.json
- **THEN** DEBE incluir grupo visibility con priority 10
- **AND** DEBE incluir grupo content con priority 20
- **AND** DEBE incluir grupo typography con priority 30
- **AND** DEBE incluir grupo colors con priority 40
- **AND** DEBE incluir grupo spacing con priority 50
- **AND** DEBE incluir grupo visual_effects con priority 60
- **AND** DEBE incluir grupo layout con priority 80
- **AND** DEBE incluir grupo media con priority 90
#### Scenario: Campos obligatorios de visibility en post-grid
- **WHEN** se define grupo visibility en schema
- **THEN** DEBE incluir is_enabled como boolean con default true
- **AND** DEBE incluir show_on_desktop como boolean con default true
- **AND** DEBE incluir show_on_mobile como boolean con default true
#### Scenario: Campos de _page_visibility en post-grid
- **WHEN** se configura visibilidad por tipo de pagina en FieldMapper
- **THEN** DEBE mapear campo show_on_home en grupo _page_visibility con default true
- **AND** DEBE mapear campo show_on_posts en grupo _page_visibility con default false
- **AND** DEBE mapear campo show_on_pages en grupo _page_visibility con default false
- **AND** DEBE mapear campo show_on_archives en grupo _page_visibility con default true
- **AND** DEBE mapear campo show_on_search en grupo _page_visibility con default true
- **NOTE** Los campos _page_visibility NO van en el schema JSON, se manejan via FieldMapper y VisibilityDefaults
- **NOTE** show_on_home en true para mostrar grid en pagina de blog principal
- **NOTE** show_on_archives en true porque este componente es para listados
- **NOTE** show_on_search en true para mostrar resultados de busqueda
#### Scenario: Campos de content en post-grid
- **WHEN** se define grupo content
- **THEN** DEBE incluir show_thumbnail como boolean con default true
- **AND** DEBE incluir show_excerpt como boolean con default true
- **AND** DEBE incluir show_meta como boolean con default true
- **AND** DEBE incluir show_categories como boolean con default true
- **AND** DEBE incluir excerpt_length como select con options ["10", "15", "20", "25", "30"] y default "20"
- **AND** DEBE incluir read_more_text como text con default "Leer mas"
- **AND** DEBE incluir no_posts_message como text con default "No se encontraron publicaciones"
#### Scenario: Campos de media en post-grid
- **WHEN** se define grupo media
- **THEN** DEBE incluir fallback_image como url con default ""
- **AND** DEBE incluir fallback_image_alt como text con default "Imagen por defecto"
- **AND** fallback_image_alt es obligatorio para accesibilidad WCAG
#### Scenario: Campos de typography en post-grid
- **WHEN** se define grupo typography
- **THEN** DEBE incluir heading_level como select con options ["h2", "h3", "h4", "h5", "h6"] y default "h3"
- **AND** DEBE incluir card_title_size como text con default "1.1rem"
- **AND** DEBE incluir card_title_weight como text con default "600"
- **AND** DEBE incluir excerpt_size como text con default "0.9rem"
- **AND** DEBE incluir meta_size como text con default "0.8rem"
#### Scenario: Campos de colors en post-grid
- **WHEN** se define grupo colors
- **THEN** DEBE incluir card_bg_color como color con default "#ffffff"
- **AND** DEBE incluir card_title_color como color con default "#0E2337"
- **AND** DEBE incluir card_hover_bg_color como color con default "#f9fafb"
- **AND** DEBE incluir card_border_color como color con default "#e5e7eb"
- **AND** DEBE incluir card_hover_border_color como color con default "#FF8600"
- **AND** DEBE incluir excerpt_color como color con default "#6b7280"
- **AND** DEBE incluir meta_color como color con default "#9ca3af"
- **AND** DEBE incluir category_bg_color como color con default "#FFF5EB"
- **AND** DEBE incluir category_text_color como color con default "#FF8600"
#### Scenario: Campos de spacing en post-grid
- **WHEN** se define grupo spacing
- **THEN** DEBE incluir grid_gap como text con default "1.5rem"
- **AND** DEBE incluir card_padding como text con default "1.25rem"
- **AND** DEBE incluir section_margin_top como text con default "0"
- **AND** DEBE incluir section_margin_bottom como text con default "2rem"
#### Scenario: Campos de visual_effects en post-grid
- **WHEN** se define grupo visual_effects
- **THEN** DEBE incluir card_border_radius como text con default "0.5rem"
- **AND** DEBE incluir card_shadow como text con default "0 1px 3px rgba(0,0,0,0.1)"
- **AND** DEBE incluir card_hover_shadow como text con default "0 4px 12px rgba(0,0,0,0.15)"
- **AND** DEBE incluir card_transition como text con default "all 0.3s ease"
- **AND** DEBE incluir image_border_radius como text con default "0.375rem"
#### Scenario: Campos de layout en post-grid
- **WHEN** se define grupo layout
- **THEN** DEBE incluir columns_desktop como select con options ["2", "3", "4"] y default "3"
- **AND** DEBE incluir columns_tablet como select con options ["1", "2", "3"] y default "2"
- **AND** DEBE incluir columns_mobile como select con options ["1", "2"] y default "1"
- **AND** DEBE incluir image_position como select con options ["top", "left", "none"] y default "top"
---
### Requirement: Manejo Graceful de Contenido Faltante
The post-grid component MUST handle missing content gracefully.
#### Scenario: Post sin imagen destacada
- **WHEN** un post no tiene thumbnail y show_thumbnail es true
- **THEN** si fallback_image tiene valor DEBE mostrar esa imagen con fallback_image_alt
- **AND** si fallback_image esta vacio DEBE omitir la imagen sin romper el layout
- **AND** NO DEBE mostrar imagen rota o placeholder generico
#### Scenario: Post sin excerpt
- **WHEN** un post no tiene excerpt y show_excerpt es true
- **THEN** DEBE generar excerpt automatico desde post_content
- **AND** DEBE respetar excerpt_length del schema
- **AND** DEBE usar wp_trim_words() para truncar
#### Scenario: Post sin categorias
- **WHEN** un post no tiene categorias y show_categories es true
- **THEN** DEBE omitir la seccion de categorias
- **AND** NO DEBE mostrar "Sin categoria" u otro texto placeholder
#### Scenario: No posts found - Query vacia
- **WHEN** have_posts() retorna false en un template de listado
- **THEN** post-grid DEBE mostrar mensaje configurable de "no hay posts"
- **AND** el mensaje DEBE usar campo no_posts_message del schema con default "No se encontraron publicaciones"
- **AND** DEBE aplicar estilos consistentes con el design system
- **AND** NO DEBE romper el layout de la pagina
---
### Requirement: Visibilidad por Tipo de Pagina
Components MUST respect the show_on_archives setting in _page_visibility group.
#### Scenario: Patron de visibilidad por tipo de pagina
- **WHEN** se implementa visibilidad por tipo de pagina
- **THEN** los campos show_on_home, show_on_posts, show_on_pages, show_on_archives, show_on_search
- **AND** DEBEN estar en grupo _page_visibility (NO en visibility)
- **AND** DEBEN mapearse via FieldMapper del componente
- **AND** DEBEN evaluarse via PageVisibilityHelper::shouldShow()
#### Scenario: Configuracion por defecto de show_on_archives para nuevos componentes
- **WHEN** se configura _page_visibility para componentes nuevos
- **THEN** archive-header DEBE tener show_on_archives true en _page_visibility
- **AND** post-grid DEBE tener show_on_archives true en _page_visibility
#### Scenario: Componentes existentes en archives
- **WHEN** se evalua que componentes mostrar en archives via _page_visibility
- **THEN** hero DEBE tener show_on_archives false por defecto (configurable)
- **AND** table-of-contents DEBE tener show_on_archives false
- **AND** featured-image DEBE tener show_on_archives false
- **AND** social-share DEBE tener show_on_archives false
- **AND** related-post DEBE tener show_on_archives false
- **AND** cta-box-sidebar DEBE tener show_on_archives true
- **AND** contact-form DEBE tener show_on_archives configurable
#### Scenario: Llamada a componente con visibilidad deshabilitada (Patron Template Unificado)
- **GIVEN** el template unificado llama a TODOS los componentes para mantener consistencia
- **WHEN** un template llama roi_render_component() para un componente
- **AND** ese componente tiene show_on_archives false
- **THEN** el componente NO DEBE renderizarse (retorna string vacio)
- **AND** esto es comportamiento correcto y esperado, NO un error
- **AND** permite que el admin habilite/deshabilite componentes sin modificar templates
- **NOTE** Por ejemplo: table-of-contents se llama en sidebar pero no renderiza en archives porque show_on_archives=false
---
### Requirement: Templates a Modernizar
These templates MUST be updated to use the unified structure.
#### Scenario: Modernizar home.php
- **WHEN** se actualiza home.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada con hero, archive-header, post-grid
#### Scenario: Modernizar archive.php
- **WHEN** se actualiza archive.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada
#### Scenario: Modernizar category.php
- **GIVEN** category.php existe en roi-theme/ (verificado)
- **WHEN** se actualiza category.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada
#### Scenario: Modernizar tag.php
- **GIVEN** tag.php existe en roi-theme/ (verificado)
- **WHEN** se actualiza tag.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada
#### Scenario: Modernizar author.php
- **GIVEN** author.php existe en roi-theme/ (verificado)
- **WHEN** se actualiza author.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada
- **AND** archive-header detectara automaticamente contexto de autor
#### Scenario: Modernizar date.php
- **GIVEN** date.php existe en roi-theme/ (verificado)
- **WHEN** se actualiza date.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada
- **AND** archive-header detectara automaticamente contexto de fecha
#### Scenario: Modernizar search.php
- **GIVEN** search.php existe en roi-theme/ (verificado)
- **WHEN** se actualiza search.php
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
- **AND** DEBE eliminar referencia a TemplateParts/content.php
- **AND** DEBE usar estructura unificada con post-grid
- **AND** archive-header detectara automaticamente contexto de busqueda mostrando "Resultados: [termino]"
---
### Requirement: Orden de Implementacion
Components and templates MUST be implemented in a specific order.
#### Scenario: Secuencia de implementacion
- **WHEN** se implementa esta especificacion
- **THEN** Fase 1 es crear componente archive-header (5 pasos del flujo)
- **AND** Fase 2 es crear componente post-grid (5 pasos del flujo)
- **AND** Fase 3 es modernizar home.php
- **AND** Fase 4 es modernizar archive.php
- **AND** Fase 5 es modernizar category.php
- **AND** Fase 6 es modernizar tag.php
- **AND** Fase 7 es modernizar author.php
- **AND** Fase 8 es modernizar date.php
- **AND** Fase 9 es modernizar search.php
- **AND** Fase 10 es configurar visibilidad de componentes existentes
#### Scenario: Cada componente sigue flujo de 5 fases
- **WHEN** se crea archive-header o post-grid
- **THEN** DEBE seguir Fase 1: Schema JSON
- **AND** DEBE seguir Fase 2: Sincronizacion wp roi-theme sync-component
- **AND** DEBE seguir Fase 3: Renderer
- **AND** DEBE seguir Fase 4: FormBuilder
- **AND** DEBE seguir Fase 5: Validacion validate-architecture.php
---
### Requirement: Dependencias Existentes
The implementation MUST use existing infrastructure.
#### Scenario: Uso de PageVisibilityHelper
- **WHEN** un Renderer verifica visibilidad
- **THEN** DEBE usar PageVisibilityHelper::shouldShow(componentName)
- **AND** esta en Shared/Infrastructure/Services/PageVisibilityHelper.php
#### Scenario: Uso de CSSGeneratorInterface
- **WHEN** un Renderer genera CSS
- **THEN** DEBE inyectar CSSGeneratorInterface via constructor
- **AND** DEBE usar $this->cssGenerator->generate()
#### Scenario: Uso de roi_should_render_any_wrapper
- **WHEN** un template determina si mostrar sidebar
- **THEN** DEBE usar roi_should_render_any_wrapper()
- **AND** esta definida en functions-addon.php linea 423
#### Scenario: Uso de DIContainer
- **WHEN** se instancian servicios
- **THEN** DEBE usar DIContainer::getInstance()
- **AND** NO DEBE instanciar servicios con new directamente

View File

@@ -2,9 +2,15 @@
/**
* The template for displaying search results pages
*
* IMPORTANT: This theme has search functionality disabled.
* All search attempts will be redirected to 404.
* This template serves as a fallback and will display a 404 error.
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* NOTA: Si se desea deshabilitar la busqueda, descomentar las lineas siguientes:
* status_header( 404 );
* nocache_headers();
* include( get_404_template() );
* exit;
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#search-result
*
@@ -12,117 +18,81 @@
* @since 1.0.0
*/
// Force 404 status
status_header( 404 );
nocache_headers();
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<section class="error-404 not-found search-disabled" aria-labelledby="search-error-title">
<!-- Archive Header - Componente dinamico (detecta busqueda automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<!-- Error Header -->
<header class="page-header">
<h1 id="search-error-title" class="page-title">
<?php esc_html_e( 'Search Unavailable', 'roi-theme' ); ?>
</h1>
</header><!-- .page-header -->
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Error Content -->
<div class="page-content">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<p class="error-message">
<?php esc_html_e( 'The search functionality is currently disabled on this website.', 'roi-theme' ); ?>
</p>
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<!-- Helpful Actions -->
<div class="error-actions">
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<h2><?php esc_html_e( 'How to find content:', 'roi-theme' ); ?></h2>
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<ul class="error-suggestions">
<li>
<a href="<?php echo esc_url( home_url( '/' ) ); ?>">
<?php esc_html_e( 'Go to the homepage', 'roi-theme' ); ?>
</a>
</li>
<li>
<?php esc_html_e( 'Use the navigation menu above', 'roi-theme' ); ?>
</li>
<li>
<?php esc_html_e( 'Browse by category below', 'roi-theme' ); ?>
</li>
</ul>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
<!-- Categories -->
<?php
$categories = get_categories(
array(
'orderby' => 'count',
'order' => 'DESC',
'number' => 10,
'hide_empty' => true,
)
);
</div><!-- .row -->
</div><!-- .container -->
if ( ! empty( $categories ) ) :
?>
<div class="categories-section">
<h3><?php esc_html_e( 'Browse by Category', 'roi-theme' ); ?></h3>
<ul class="categories-list" role="list">
<?php foreach ( $categories as $category ) : ?>
<li>
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>">
<?php echo esc_html( $category->name ); ?>
<span class="category-count">(<?php echo esc_html( $category->count ); ?>)</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</main><!-- #main-content -->
<!-- Recent Posts -->
<?php
$recent_posts = wp_get_recent_posts(
array(
'numberposts' => 10,
'post_status' => 'publish',
)
);
if ( ! empty( $recent_posts ) ) :
?>
<div class="recent-posts-section">
<h3><?php esc_html_e( 'Recent Posts', 'roi-theme' ); ?></h3>
<ul class="recent-posts-list" role="list">
<?php foreach ( $recent_posts as $recent ) : ?>
<li>
<a href="<?php echo esc_url( get_permalink( $recent['ID'] ) ); ?>">
<?php echo esc_html( $recent['post_title'] ); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php
wp_reset_postdata();
endif;
?>
</div><!-- .error-actions -->
</div><!-- .page-content -->
</section><!-- .error-404 -->
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

167
tag.php
View File

@@ -2,8 +2,9 @@
/**
* The template for displaying tag archive pages
*
* This template displays posts associated with a specific tag,
* with tag information displayed at the top.
* Estructura unificada siguiendo el patron de single.php.
* Usa roi_render_component() para todos los componentes.
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#tag
*
@@ -14,118 +15,78 @@
get_header();
?>
<main id="main-content" class="site-main" role="main">
<main id="main-content" class="site-main" role="main">
<div class="content-wrapper">
<!-- Hero Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('hero');
}
?>
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<!-- Archive Header - Componente dinamico (detecta tag automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-header');
}
?>
<?php if ( have_posts() ) : ?>
<!-- Main Content Grid -->
<?php
// Determinar si mostrar sidebar basandose en visibilidad de componentes
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
$show_sidebar = function_exists('roi_should_render_any_wrapper')
? roi_should_render_any_wrapper($sidebar_components)
: false;
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
?>
<div class="container">
<div class="row">
<!-- Tag Archive Header -->
<header class="page-header tag-header">
<?php
// Tag title
the_archive_title( '<h1 class="page-title tag-title">', '</h1>' );
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
// Tag description
$tag_description = tag_description();
if ( ! empty( $tag_description ) ) :
?>
<div class="archive-description tag-description">
<?php echo wp_kses_post( wpautop( $tag_description ) ); ?>
</div>
<?php endif; ?>
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
<!-- Tag metadata -->
<?php
$tag = get_queried_object();
if ( $tag ) :
?>
<div class="tag-meta">
<span class="tag-count">
<?php
printf(
/* translators: %s: number of posts */
esc_html( _n( '%s post', '%s posts', $tag->count, 'roi-theme' ) ),
esc_html( number_format_i18n( $tag->count ) )
);
?>
</span>
</div>
<?php endif; ?>
</header><!-- .page-header -->
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<!-- Tag Posts Loop -->
<div class="archive-posts tag-posts">
<?php if ($show_sidebar): ?>
<!-- Sidebar Column (col-lg-3) -->
<div class="col-lg-3">
<div class="sidebar-sticky">
<!-- Table of Contents - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('table-of-contents');
}
?>
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div>
<?php endif; ?>
/**
* Include the Post-Type-specific template for the content.
* If you want to override this in a child theme, then include a file
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
*/
get_template_part( 'template-parts/content', get_post_type() );
</div><!-- .row -->
</div><!-- .container -->
endwhile;
?>
</main><!-- #main-content -->
</div><!-- .archive-posts -->
<?php
/**
* Pagination
* Display navigation to next/previous set of posts when applicable.
*/
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => sprintf(
'%s <span class="nav-prev-text">%s</span>',
'<span class="nav-icon" aria-hidden="true">&laquo;</span>',
esc_html__( 'Previous', 'roi-theme' )
),
'next_text' => sprintf(
'<span class="nav-next-text">%s</span> %s',
esc_html__( 'Next', 'roi-theme' ),
'<span class="nav-icon" aria-hidden="true">&raquo;</span>'
),
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
)
);
else :
/**
* No posts found
* Display a message when no content is available.
*/
get_template_part( 'template-parts/content', 'none' );
endif;
?>
</div><!-- #primary -->
<?php
/**
* Sidebar
* Display the sidebar if it's active.
*/
if ( is_active_sidebar( 'sidebar-1' ) ) :
get_sidebar();
endif;
?>
</div><!-- .content-wrapper -->
</main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php
get_footer();

27
test-shortcode.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
/**
* Template Name: Test Post Grid Shortcode
*
* Página de prueba para el shortcode [roi_post_grid]
*/
get_header();
?>
<main class="container py-5">
<h1>Prueba de Shortcode [roi_post_grid]</h1>
<h2>Test 1: Grid básico (9 posts)</h2>
<?php echo do_shortcode('[roi_post_grid]'); ?>
<hr class="my-5">
<h2>Test 2: 6 posts en 2 columnas</h2>
<?php echo do_shortcode('[roi_post_grid posts_per_page="6" columns="2"]'); ?>
<hr class="my-5">
<h2>Test 3: 4 posts en 4 columnas sin meta</h2>
<?php echo do_shortcode('[roi_post_grid posts_per_page="4" columns="4" show_meta="false"]'); ?>
</main>
<?php get_footer(); ?>