2 Commits

Author SHA1 Message Date
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
25 changed files with 3641 additions and 852 deletions

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); $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true); $showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', 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); $showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
// Grid 3 columnas según Design System // Grid 3 columnas según Design System

View File

@@ -98,6 +98,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'Related Posts', 'label' => 'Related Posts',
'icon' => 'bi-grid-3x3-gap', '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' => [ 'contact-form' => [
'id' => 'contact-form', 'id' => 'contact-form',
'label' => 'Contact Form', 'label' => 'Contact Form',

View File

@@ -37,7 +37,7 @@ final class ComponentGroupRegistry
'label' => __('Contenido Principal', 'roi-theme'), 'label' => __('Contenido Principal', 'roi-theme'),
'icon' => 'bi-file-richtext', 'icon' => 'bi-file-richtext',
'description' => __('Secciones principales de páginas y posts', 'roi-theme'), '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' => [ 'ctas-conversion' => [
'label' => __('CTAs & Conversión', 'roi-theme'), 'label' => __('CTAs & Conversión', 'roi-theme'),

View File

@@ -0,0 +1,97 @@
<?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
'postGridGridGap' => ['group' => 'spacing', 'attribute' => 'grid_gap'],
'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,619 @@
<?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->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildLayoutGroup($componentId);
$html .= $this->buildMediaGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$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="postGridColsDesktop" 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="postGridColsDesktop" 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="postGridColsTablet" 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="postGridColsTablet" 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="postGridColsMobile" 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="postGridColsMobile" 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>';
$html .= ' <div class="row g-2 mb-3">';
$gridGap = $this->renderer->getFieldValue($componentId, 'spacing', 'grid_gap', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridGridGap" class="form-label small mb-1 fw-semibold">Espacio entre cards</label>';
$html .= ' <input type="text" id="postGridGridGap" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($gridGap) . '">';
$html .= ' </div>';
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '1.25rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">Padding card</label>';
$html .= ' <input type="text" id="postGridCardPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardPadding) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="postGridSectionMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginTop) . '">';
$html .= ' </div>';
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="postGridSectionMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginBottom) . '">';
$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;
}
}

View File

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

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,572 @@
<?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
$gridGap = $spacing['grid_gap'] ?? '1.5rem';
$cardPadding = $spacing['card_padding'] ?? '1.25rem';
$sectionMarginTop = $spacing['section_margin_top'] ?? '0';
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '2rem';
// 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 gap
$cssRules[] = $this->cssGenerator->generate('.post-grid .row', [
'gap' => $gridGap,
'row-gap' => $gridGap,
]);
// Card base
$cssRules[] = ".post-grid .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
border-radius: {$cardBorderRadius};
box-shadow: {$cardShadow};
transition: {$cardTransition};
height: 100%;
overflow: hidden;
}";
// 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
$mobileWidth = $this->getColumnWidth($colsMobile);
$cssRules[] = "@media (max-width: 575.98px) {
.post-grid .post-card-col {
flex: 0 0 {$mobileWidth};
max-width: {$mobileWidth};
}
}";
// Tablet
$tabletWidth = $this->getColumnWidth($colsTablet);
$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);
$cssRules[] = "@media (min-width: 992px) {
.post-grid .post-card-col {
flex: 0 0 {$desktopWidth};
max-width: {$desktopWidth};
}
}";
return implode("\n", $cssRules);
}
private function getColumnWidth(string $cols): string
{
$colCount = (int)$cols;
if ($colCount <= 0) {
$colCount = 1;
}
$percentage = 100 / $colCount;
return sprintf('%.4f%%', $percentage);
}
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
{
ob_start();
the_posts_pagination([
'mid_size' => 2,
'prev_text' => __('&laquo; Anterior', 'roi-theme'),
'next_text' => __('Siguiente &raquo;', 'roi-theme'),
'screen_reader_text' => __('Navegacion de publicaciones', 'roi-theme'),
]);
return ob_get_clean();
}
}

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

@@ -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 public function execute(string $componentName): bool
{ {
$config = $this->visibilityRepository->getVisibilityConfig($componentName); $config = $this->visibilityRepository->getVisibilityConfig($componentName);
if (empty($config)) { if (empty($config)) {
// Usar constante compartida (DRY) // Usar defaults especificos por componente si existen
$config = VisibilityDefaults::DEFAULT_VISIBILITY; $config = VisibilityDefaults::getForComponent($componentName);
} }
$pageType = $this->pageTypeDetector->detect(); $pageType = $this->pageTypeDetector->detect();

View File

@@ -16,13 +16,13 @@ namespace ROITheme\Shared\Domain\Constants;
final class VisibilityDefaults 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) * - Home: SI mostrar (pagina principal)
* - Posts: SÍ mostrar (artículos del blog) * - Posts: SI mostrar (articulos del blog)
* - Pages: SÍ mostrar (páginas estáticas) * - Pages: SI mostrar (paginas estaticas)
* - Archives: NO mostrar (listados de categorías/tags) * - Archives: NO mostrar (listados de categorias/tags)
* - Search: NO mostrar (resultados de búsqueda) * - Search: NO mostrar (resultados de busqueda)
*/ */
public const DEFAULT_VISIBILITY = [ public const DEFAULT_VISIBILITY = [
'show_on_home' => true, '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 = [ public const VISIBILITY_FIELDS = [
'show_on_home', 'show_on_home',
@@ -42,4 +74,15 @@ final class VisibilityDefaults
'show_on_archives', 'show_on_archives',
'show_on_search', '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

@@ -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} * @return array{created: int, skipped: int}
*/ */
@@ -37,10 +37,10 @@ final class MigratePageVisibilityService
continue; continue;
} }
// Usar constante compartida (DRY) // Usar defaults especificos por componente si existen
$this->visibilityRepository->createDefaultVisibility( $this->visibilityRepository->createDefaultVisibility(
$componentName, $componentName,
VisibilityDefaults::DEFAULT_VISIBILITY VisibilityDefaults::getForComponent($componentName)
); );
$created++; $created++;
} }

View File

@@ -40,7 +40,10 @@ final class WordPressPageTypeDetector implements PageTypeDetectorInterface
public function isHome(): bool 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 public function isPost(): bool

View File

@@ -2,8 +2,9 @@
/** /**
* The template for displaying archive pages * The template for displaying archive pages
* *
* This template displays date-based, category, tag, author, and post type * Estructura unificada siguiendo el patron de single.php.
* archives with a dynamic title, description, and post loop. * 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 * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#archive
* *
@@ -16,211 +17,76 @@ 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 -->
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<?php if ( have_posts() ) : ?>
<!-- Archive Header -->
<header class="page-header">
<?php <?php
// Archive title if (function_exists('roi_render_component')) {
the_archive_title( '<h1 class="page-title">', '</h1>' ); echo roi_render_component('hero');
}
// Archive description ?>
$archive_description = get_the_archive_description();
if ( ! empty( $archive_description ) ) : <!-- Archive Header - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?> ?>
<div class="archive-description">
<?php echo wp_kses_post( wpautop( $archive_description ) ); ?>
</div> </div>
<?php endif; ?>
</header><!-- .page-header -->
<!-- Archive Posts Loop -->
<div class="archive-posts">
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_post();
?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<!-- 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> </div>
<?php endif; ?> <?php endif; ?>
<!-- Post Content --> </div><!-- .row -->
<div class="post-content"> </div><!-- .container -->
<!-- Post Header -->
<header class="entry-header">
<!-- 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; ?>
<!-- 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;
?>
<!-- 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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();

View File

@@ -2,8 +2,9 @@
/** /**
* The template for displaying author archive pages * The template for displaying author archive pages
* *
* This template displays posts from a specific author with author * Estructura unificada siguiendo el patron de single.php.
* bio and information at the top. * 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 * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#author
* *
@@ -16,148 +17,76 @@ 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 -->
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<?php if ( have_posts() ) : ?>
<!-- Author Archive Header -->
<header class="page-header author-header">
<?php <?php
// Get the author if (function_exists('roi_render_component')) {
$author = get_queried_object(); echo roi_render_component('hero');
}
?> ?>
<div class="author-info"> <!-- Archive Header - Componente dinamico (detecta autor automaticamente) -->
<!-- Author Avatar -->
<div class="author-avatar">
<?php <?php
echo get_avatar( if (function_exists('roi_render_component')) {
$author->ID, echo roi_render_component('archive-header');
120, }
'', ?>
sprintf(
/* translators: %s: author name */ <!-- Main Content Grid -->
esc_attr__( 'Avatar for %s', 'roi-theme' ), <?php
esc_html( $author->display_name ) // Determinar si mostrar sidebar basandose en visibilidad de componentes
), $sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
array( $show_sidebar = function_exists('roi_should_render_any_wrapper')
'class' => 'author-avatar-img', ? 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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?> ?>
</div> </div>
<!-- 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>
<!-- 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> </div>
<?php endif; ?> <?php endif; ?>
<!-- Author Stats --> </div><!-- .row -->
<div class="author-meta"> </div><!-- .container -->
<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 -->
<!-- Author Posts Loop -->
<div class="archive-posts author-posts">
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_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() );
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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();

View File

@@ -2,8 +2,9 @@
/** /**
* The template for displaying category archive pages * The template for displaying category archive pages
* *
* This template displays posts from a specific category with category * Estructura unificada siguiendo el patron de single.php.
* information and description at the top. * 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 * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#category
* *
@@ -16,116 +17,76 @@ 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 -->
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<?php if ( have_posts() ) : ?>
<!-- Category Archive Header -->
<header class="page-header category-header">
<?php <?php
// Category title if (function_exists('roi_render_component')) {
the_archive_title( '<h1 class="page-title category-title">', '</h1>' ); echo roi_render_component('hero');
}
// Category description
$category_description = category_description();
if ( ! empty( $category_description ) ) :
?> ?>
<div class="archive-description category-description">
<?php echo wp_kses_post( wpautop( $category_description ) ); ?> <!-- Archive Header - Componente dinamico (detecta categoria automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Category metadata --> </div><!-- .row -->
<?php </div><!-- .container -->
$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 -->
<!-- Category Posts Loop -->
<div class="archive-posts category-posts">
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_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() );
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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();

168
date.php
View File

@@ -2,8 +2,9 @@
/** /**
* The template for displaying date-based archive pages * The template for displaying date-based archive pages
* *
* This template displays posts from a specific date (year, month, or day) * Estructura unificada siguiendo el patron de single.php.
* with the date information displayed at the top. * 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 * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#date
* *
@@ -16,113 +17,76 @@ 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 -->
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<?php if ( have_posts() ) : ?>
<!-- Date Archive Header -->
<header class="page-header date-header">
<?php <?php
// Date archive title if (function_exists('roi_render_component')) {
the_archive_title( '<h1 class="page-title date-title">', '</h1>' ); echo roi_render_component('hero');
}
// 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 ) ); ?> <!-- Archive Header - Componente dinamico (detecta fecha automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Date metadata --> </div><!-- .row -->
<div class="date-meta"> </div><!-- .container -->
<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 -->
<!-- Date Posts Loop -->
<div class="archive-posts date-posts">
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_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() );
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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();

View File

@@ -207,8 +207,17 @@ function roi_render_component(string $componentName): string {
// Decodificar valor // Decodificar valor
$value = $row->attribute_value; $value = $row->attribute_value;
// Convertir booleanos almacenados como '1' o '0' // Solo convertir a booleano campos que realmente son booleanos
if ($value === '1' || $value === '0') { // 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'); $value = ($value === '1');
} else { } else {
// Intentar decodificar JSON // Intentar decodificar JSON
@@ -321,6 +330,12 @@ function roi_render_component(string $componentName): string {
case 'footer': case 'footer':
$renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator); $renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator);
break; 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) { if (!$renderer) {

159
home.php
View File

@@ -2,9 +2,9 @@
/** /**
* The template for displaying the blog posts index * The template for displaying the blog posts index
* *
* This template is used when the blog posts page is different from the front page. * Estructura unificada siguiendo el patron de single.php.
* It displays a listing of recent blog posts with pagination. * Usa roi_render_component() para todos los componentes.
* Set in WordPress Settings > Reading > "Posts page". * La visibilidad se controla via PageVisibilityHelper::shouldShow().
* *
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#home * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#home
* *
@@ -17,107 +17,76 @@ 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 -->
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<?php if ( have_posts() ) : ?>
<!-- Blog Header -->
<header class="page-header">
<?php <?php
// Display blog page title if (function_exists('roi_render_component')) {
if ( is_home() && ! is_front_page() && get_option( 'page_for_posts' ) ) : echo roi_render_component('hero');
}
?> ?>
<h1 class="page-title">
<?php echo esc_html( get_the_title( get_option( 'page_for_posts' ) ) ); ?> <!-- Archive Header - Componente dinamico -->
</h1>
<?php <?php
// Display blog page description if available if (function_exists('roi_render_component')) {
$blog_page = get_post( get_option( 'page_for_posts' ) ); echo roi_render_component('archive-header');
if ( $blog_page && ! empty( $blog_page->post_content ) ) : }
?> ?>
<div class="page-description">
<?php echo wp_kses_post( wpautop( $blog_page->post_excerpt ) ); ?> <!-- 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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php else : ?>
<h1 class="page-title">
<?php esc_html_e( 'Blog', 'roi-theme' ); ?>
</h1>
<?php endif; ?>
</header><!-- .page-header -->
<!-- Blog Posts Loop --> </div><!-- .row -->
<div class="blog-posts"> </div><!-- .container -->
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_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() );
endwhile;
?>
</div><!-- .blog-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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();

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 * The template for displaying search results pages
* *
* IMPORTANT: This theme has search functionality disabled. * Estructura unificada siguiendo el patron de single.php.
* All search attempts will be redirected to 404. * Usa roi_render_component() para todos los componentes.
* This template serves as a fallback and will display a 404 error. * 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 * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#search-result
* *
@@ -12,117 +18,81 @@
* @since 1.0.0 * @since 1.0.0
*/ */
// Force 404 status
status_header( 404 );
nocache_headers();
get_header(); 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 -->
<section class="error-404 not-found search-disabled" aria-labelledby="search-error-title">
<!-- 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 -->
<!-- Error Content -->
<div class="page-content">
<p class="error-message">
<?php esc_html_e( 'The search functionality is currently disabled on this website.', 'roi-theme' ); ?>
</p>
<!-- Helpful Actions -->
<div class="error-actions">
<h2><?php esc_html_e( 'How to find content:', 'roi-theme' ); ?></h2>
<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>
<!-- Categories -->
<?php <?php
$categories = get_categories( if (function_exists('roi_render_component')) {
array( echo roi_render_component('hero');
'orderby' => 'count', }
'order' => 'DESC',
'number' => 10,
'hide_empty' => true,
)
);
if ( ! empty( $categories ) ) :
?> ?>
<div class="categories-section">
<h3><?php esc_html_e( 'Browse by Category', 'roi-theme' ); ?></h3> <!-- Archive Header - Componente dinamico (detecta busqueda automaticamente) -->
<ul class="categories-list" role="list"> <?php
<?php foreach ( $categories as $category ) : ?> if (function_exists('roi_render_component')) {
<li> echo roi_render_component('archive-header');
<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> <!-- Main Content Grid -->
</li> <?php
<?php endforeach; ?> // Determinar si mostrar sidebar basandose en visibilidad de componentes
</ul> $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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Recent Posts --> </div><!-- .row -->
<?php </div><!-- .container -->
$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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();

171
tag.php
View File

@@ -2,8 +2,9 @@
/** /**
* The template for displaying tag archive pages * The template for displaying tag archive pages
* *
* This template displays posts associated with a specific tag, * Estructura unificada siguiendo el patron de single.php.
* with tag information displayed at the top. * 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 * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#tag
* *
@@ -16,116 +17,76 @@ 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 -->
<!-- Primary Content Area -->
<div id="primary" class="content-area">
<?php if ( have_posts() ) : ?>
<!-- Tag Archive Header -->
<header class="page-header tag-header">
<?php <?php
// Tag title if (function_exists('roi_render_component')) {
the_archive_title( '<h1 class="page-title tag-title">', '</h1>' ); echo roi_render_component('hero');
}
// Tag description
$tag_description = tag_description();
if ( ! empty( $tag_description ) ) :
?> ?>
<div class="archive-description tag-description">
<?php echo wp_kses_post( wpautop( $tag_description ) ); ?> <!-- Archive Header - Componente dinamico (detecta tag automaticamente) -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('archive-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">
<!-- Main Content Column -->
<div class="<?php echo esc_attr($main_col_class); ?>">
<!-- Post Grid - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('post-grid');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?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');
}
?>
<!-- CTA Box Sidebar - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-box-sidebar');
}
?>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Tag metadata --> </div><!-- .row -->
<?php </div><!-- .container -->
$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 -->
<!-- Tag Posts Loop -->
<div class="archive-posts tag-posts">
<?php
// Start the WordPress Loop
while ( have_posts() ) :
the_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() );
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 --> </main><!-- #main-content -->
<!-- Contact Form Section - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('contact-form');
}
?>
<?php <?php
get_footer(); get_footer();