Compare commits
14 Commits
a33c43a104
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f3387fd2 | ||
|
|
ff5ba25505 | ||
|
|
eab974d14c | ||
|
|
b509b1a2b4 | ||
|
|
83d113d669 | ||
|
|
0c1908e7d1 | ||
|
|
5333531be4 | ||
|
|
fb68f2023c | ||
|
|
79e91f59ee | ||
|
|
c23dc22d76 | ||
|
|
b79569c5e7 | ||
|
|
6be292e085 | ||
|
|
885276aad1 | ||
|
|
1e6a076904 |
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ final class CtaBoxSidebarFormBuilder
|
||||
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
|
||||
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
// Grid 3 columnas según Design System
|
||||
|
||||
@@ -8,9 +8,10 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
|
||||
/**
|
||||
* Value Object para ID único de snippet CSS
|
||||
*
|
||||
* Soporta dos formatos:
|
||||
* Soporta tres formatos:
|
||||
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
|
||||
* 2. Legacy/Migración: kebab-case (ej: "cls-tables-apu", "generic-tables")
|
||||
* 2. Legacy con prefijo: css_[descriptive]_[number] (ej: "css_tablas_apu_1764624826")
|
||||
* 3. Legacy kebab-case: (ej: "cls-tables-apu", "generic-tables")
|
||||
*
|
||||
* Esto permite migrar snippets existentes sin romper IDs.
|
||||
*/
|
||||
@@ -18,6 +19,7 @@ final class SnippetId
|
||||
{
|
||||
private const PREFIX = 'css_';
|
||||
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/';
|
||||
private const PATTERN_LEGACY_PREFIX = '/^css_[a-z0-9_]+$/';
|
||||
private const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
|
||||
|
||||
private function __construct(
|
||||
@@ -47,7 +49,8 @@ final class SnippetId
|
||||
|
||||
// Validar formato generado (css_*)
|
||||
if (str_starts_with($id, self::PREFIX)) {
|
||||
if (!preg_match(self::PATTERN_GENERATED, $id)) {
|
||||
// Acepta formato nuevo (css_timestamp_random) o legacy (css_descriptivo_numero)
|
||||
if (!preg_match(self::PATTERN_GENERATED, $id) && !preg_match(self::PATTERN_LEGACY_PREFIX, $id)) {
|
||||
throw new ValidationException(
|
||||
sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id)
|
||||
);
|
||||
|
||||
@@ -98,6 +98,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
|
||||
'label' => 'Related Posts',
|
||||
'icon' => 'bi-grid-3x3-gap',
|
||||
],
|
||||
'archive-header' => [
|
||||
'id' => 'archive-header',
|
||||
'label' => 'Archive Header',
|
||||
'icon' => 'bi-layout-text-window',
|
||||
],
|
||||
'post-grid' => [
|
||||
'id' => 'post-grid',
|
||||
'label' => 'Post Grid',
|
||||
'icon' => 'bi-grid-3x3',
|
||||
],
|
||||
'contact-form' => [
|
||||
'id' => 'contact-form',
|
||||
'label' => 'Contact Form',
|
||||
|
||||
@@ -37,7 +37,7 @@ final class ComponentGroupRegistry
|
||||
'label' => __('Contenido Principal', 'roi-theme'),
|
||||
'icon' => 'bi-file-richtext',
|
||||
'description' => __('Secciones principales de páginas y posts', 'roi-theme'),
|
||||
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post']
|
||||
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post', 'archive-header', 'post-grid']
|
||||
],
|
||||
'ctas-conversion' => [
|
||||
'label' => __('CTAs & Conversión', 'roi-theme'),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\PostGrid\Infrastructure\FieldMapping;
|
||||
|
||||
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
|
||||
|
||||
/**
|
||||
* Field Mapper para Post Grid
|
||||
*
|
||||
* RESPONSABILIDAD:
|
||||
* - Mapear field IDs del formulario a atributos de BD
|
||||
* - Solo conoce sus propios campos (modularidad)
|
||||
*/
|
||||
final class PostGridFieldMapper implements FieldMapperInterface
|
||||
{
|
||||
public function getComponentName(): string
|
||||
{
|
||||
return 'post-grid';
|
||||
}
|
||||
|
||||
public function getFieldMapping(): array
|
||||
{
|
||||
return [
|
||||
// Visibility
|
||||
'postGridEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'postGridShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'postGridShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'postGridVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'postGridVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'postGridVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'postGridVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'postGridVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions)
|
||||
'postGridExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'postGridExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'postGridExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'postGridExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'postGridShowThumbnail' => ['group' => 'content', 'attribute' => 'show_thumbnail'],
|
||||
'postGridShowExcerpt' => ['group' => 'content', 'attribute' => 'show_excerpt'],
|
||||
'postGridShowMeta' => ['group' => 'content', 'attribute' => 'show_meta'],
|
||||
'postGridShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
|
||||
'postGridExcerptLength' => ['group' => 'content', 'attribute' => 'excerpt_length'],
|
||||
'postGridReadMoreText' => ['group' => 'content', 'attribute' => 'read_more_text'],
|
||||
'postGridNoPostsMessage' => ['group' => 'content', 'attribute' => 'no_posts_message'],
|
||||
|
||||
// Layout
|
||||
'postGridColumnsDesktop' => ['group' => 'layout', 'attribute' => 'columns_desktop'],
|
||||
'postGridColumnsTablet' => ['group' => 'layout', 'attribute' => 'columns_tablet'],
|
||||
'postGridColumnsMobile' => ['group' => 'layout', 'attribute' => 'columns_mobile'],
|
||||
'postGridImagePosition' => ['group' => 'layout', 'attribute' => 'image_position'],
|
||||
|
||||
// Media
|
||||
'postGridFallbackImage' => ['group' => 'media', 'attribute' => 'fallback_image'],
|
||||
'postGridFallbackImageAlt' => ['group' => 'media', 'attribute' => 'fallback_image_alt'],
|
||||
|
||||
// Typography
|
||||
'postGridHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
|
||||
'postGridCardTitleSize' => ['group' => 'typography', 'attribute' => 'card_title_size'],
|
||||
'postGridCardTitleWeight' => ['group' => 'typography', 'attribute' => 'card_title_weight'],
|
||||
'postGridExcerptSize' => ['group' => 'typography', 'attribute' => 'excerpt_size'],
|
||||
'postGridMetaSize' => ['group' => 'typography', 'attribute' => 'meta_size'],
|
||||
|
||||
// Colors
|
||||
'postGridCardBgColor' => ['group' => 'colors', 'attribute' => 'card_bg_color'],
|
||||
'postGridCardTitleColor' => ['group' => 'colors', 'attribute' => 'card_title_color'],
|
||||
'postGridCardHoverBgColor' => ['group' => 'colors', 'attribute' => 'card_hover_bg_color'],
|
||||
'postGridCardBorderColor' => ['group' => 'colors', 'attribute' => 'card_border_color'],
|
||||
'postGridCardHoverBorderColor' => ['group' => 'colors', 'attribute' => 'card_hover_border_color'],
|
||||
'postGridExcerptColor' => ['group' => 'colors', 'attribute' => 'excerpt_color'],
|
||||
'postGridMetaColor' => ['group' => 'colors', 'attribute' => 'meta_color'],
|
||||
'postGridCategoryBgColor' => ['group' => 'colors', 'attribute' => 'category_bg_color'],
|
||||
'postGridCategoryTextColor' => ['group' => 'colors', 'attribute' => 'category_text_color'],
|
||||
'postGridPaginationColor' => ['group' => 'colors', 'attribute' => 'pagination_color'],
|
||||
'postGridPaginationActiveBg' => ['group' => 'colors', 'attribute' => 'pagination_active_bg'],
|
||||
'postGridPaginationActiveColor' => ['group' => 'colors', 'attribute' => 'pagination_active_color'],
|
||||
|
||||
// Spacing
|
||||
'postGridGapHorizontal' => ['group' => 'spacing', 'attribute' => 'gap_horizontal'],
|
||||
'postGridGapVertical' => ['group' => 'spacing', 'attribute' => 'gap_vertical'],
|
||||
'postGridCardPadding' => ['group' => 'spacing', 'attribute' => 'card_padding'],
|
||||
'postGridSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
|
||||
'postGridSectionMarginBottom' => ['group' => 'spacing', 'attribute' => 'section_margin_bottom'],
|
||||
|
||||
// Visual Effects
|
||||
'postGridCardBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'card_border_radius'],
|
||||
'postGridCardShadow' => ['group' => 'visual_effects', 'attribute' => 'card_shadow'],
|
||||
'postGridCardHoverShadow' => ['group' => 'visual_effects', 'attribute' => 'card_hover_shadow'],
|
||||
'postGridCardTransition' => ['group' => 'visual_effects', 'attribute' => 'card_transition'],
|
||||
'postGridImageBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'image_border_radius'],
|
||||
];
|
||||
}
|
||||
}
|
||||
781
Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php
Normal file
781
Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php
Normal file
@@ -0,0 +1,781 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\PostGrid\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* PostGridFormBuilder - Genera formulario admin para Post Grid
|
||||
*
|
||||
* Sigue el mismo patron visual que RelatedPostFormBuilder:
|
||||
* - Header con gradiente navy
|
||||
* - Layout de 2 columnas
|
||||
* - Cards con borde izquierdo
|
||||
* - Inputs compactos (form-control-sm)
|
||||
*
|
||||
* @package ROITheme\Admin\PostGrid\Infrastructure\Ui
|
||||
*/
|
||||
final class PostGridFormBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private AdminDashboardRenderer $renderer
|
||||
) {}
|
||||
|
||||
public function buildForm(string $componentId): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= $this->buildHeader();
|
||||
|
||||
$html .= '<div class="row g-3">';
|
||||
|
||||
// Columna izquierda
|
||||
$html .= '<div class="col-lg-6">';
|
||||
$html .= $this->buildShortcodeGuide();
|
||||
$html .= $this->buildVisibilityGroup($componentId);
|
||||
$html .= $this->buildContentGroup($componentId);
|
||||
$html .= $this->buildMediaGroup($componentId);
|
||||
$html .= '</div>';
|
||||
|
||||
// Columna derecha
|
||||
$html .= '<div class="col-lg-6">';
|
||||
$html .= $this->buildLayoutGroup($componentId);
|
||||
$html .= $this->buildTypographyGroup($componentId);
|
||||
$html .= $this->buildColorsGroup($componentId);
|
||||
$html .= $this->buildSpacingGroup($componentId);
|
||||
$html .= $this->buildEffectsGroup($componentId);
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildHeader(): string
|
||||
{
|
||||
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
|
||||
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
|
||||
$html .= ' <div>';
|
||||
$html .= ' <h3 class="h4 mb-1 fw-bold">';
|
||||
$html .= ' <i class="bi bi-grid-3x3-gap me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Configuracion de Post Grid';
|
||||
$html .= ' </h3>';
|
||||
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
|
||||
$html .= ' Grid de posts para listados, archivos y resultados de busqueda';
|
||||
$html .= ' </p>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="post-grid">';
|
||||
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
|
||||
$html .= ' Restaurar valores por defecto';
|
||||
$html .= ' </button>';
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildVisibilityGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Visibilidad';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
|
||||
$html .= $this->buildSwitch('postGridEnabled', 'Activar componente', 'bi-power', $enabled);
|
||||
|
||||
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
|
||||
$html .= $this->buildSwitch('postGridShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
|
||||
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('postGridShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en tipos de pagina';
|
||||
$html .= ' </p>';
|
||||
|
||||
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
|
||||
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', false);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Reglas de exclusion
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'postGrid');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildContentGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Contenido';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Switches de contenido
|
||||
$showThumbnail = $this->renderer->getFieldValue($componentId, 'content', 'show_thumbnail', true);
|
||||
$html .= $this->buildSwitch('postGridShowThumbnail', 'Mostrar imagen destacada', 'bi-image', $showThumbnail);
|
||||
|
||||
$showExcerpt = $this->renderer->getFieldValue($componentId, 'content', 'show_excerpt', true);
|
||||
$html .= $this->buildSwitch('postGridShowExcerpt', 'Mostrar extracto', 'bi-text-paragraph', $showExcerpt);
|
||||
|
||||
$showMeta = $this->renderer->getFieldValue($componentId, 'content', 'show_meta', true);
|
||||
$html .= $this->buildSwitch('postGridShowMeta', 'Mostrar metadatos', 'bi-info-circle', $showMeta);
|
||||
|
||||
$showCategories = $this->renderer->getFieldValue($componentId, 'content', 'show_categories', true);
|
||||
$html .= $this->buildSwitch('postGridShowCategories', 'Mostrar categorias', 'bi-folder', $showCategories);
|
||||
|
||||
$html .= ' <hr class="my-3">';
|
||||
|
||||
// Excerpt length
|
||||
$excerptLength = $this->renderer->getFieldValue($componentId, 'content', 'excerpt_length', '20');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridExcerptLength" class="form-label small mb-1 fw-semibold">Longitud del extracto</label>';
|
||||
$html .= ' <select id="postGridExcerptLength" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="10"' . ($excerptLength === '10' ? ' selected' : '') . '>10 palabras</option>';
|
||||
$html .= ' <option value="15"' . ($excerptLength === '15' ? ' selected' : '') . '>15 palabras</option>';
|
||||
$html .= ' <option value="20"' . ($excerptLength === '20' ? ' selected' : '') . '>20 palabras</option>';
|
||||
$html .= ' <option value="25"' . ($excerptLength === '25' ? ' selected' : '') . '>25 palabras</option>';
|
||||
$html .= ' <option value="30"' . ($excerptLength === '30' ? ' selected' : '') . '>30 palabras</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Read more text
|
||||
$readMoreText = $this->renderer->getFieldValue($componentId, 'content', 'read_more_text', 'Leer mas');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridReadMoreText" class="form-label small mb-1 fw-semibold">Texto de leer mas</label>';
|
||||
$html .= ' <input type="text" id="postGridReadMoreText" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($readMoreText) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// No posts message
|
||||
$noPostsMessage = $this->renderer->getFieldValue($componentId, 'content', 'no_posts_message', 'No se encontraron publicaciones');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="postGridNoPostsMessage" class="form-label small mb-1 fw-semibold">Mensaje sin posts</label>';
|
||||
$html .= ' <input type="text" id="postGridNoPostsMessage" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($noPostsMessage) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildLayoutGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-grid me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Disposicion';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Columns desktop
|
||||
$colsDesktop = $this->renderer->getFieldValue($componentId, 'layout', 'columns_desktop', '3');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridColumnsDesktop" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Columnas escritorio';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridColumnsDesktop" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="2"' . ($colsDesktop === '2' ? ' selected' : '') . '>2 columnas</option>';
|
||||
$html .= ' <option value="3"' . ($colsDesktop === '3' ? ' selected' : '') . '>3 columnas</option>';
|
||||
$html .= ' <option value="4"' . ($colsDesktop === '4' ? ' selected' : '') . '>4 columnas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Columns tablet
|
||||
$colsTablet = $this->renderer->getFieldValue($componentId, 'layout', 'columns_tablet', '2');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridColumnsTablet" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-tablet me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Columnas tablet';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridColumnsTablet" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="1"' . ($colsTablet === '1' ? ' selected' : '') . '>1 columna</option>';
|
||||
$html .= ' <option value="2"' . ($colsTablet === '2' ? ' selected' : '') . '>2 columnas</option>';
|
||||
$html .= ' <option value="3"' . ($colsTablet === '3' ? ' selected' : '') . '>3 columnas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Columns mobile
|
||||
$colsMobile = $this->renderer->getFieldValue($componentId, 'layout', 'columns_mobile', '1');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridColumnsMobile" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Columnas movil';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridColumnsMobile" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="1"' . ($colsMobile === '1' ? ' selected' : '') . '>1 columna</option>';
|
||||
$html .= ' <option value="2"' . ($colsMobile === '2' ? ' selected' : '') . '>2 columnas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Image position
|
||||
$imagePosition = $this->renderer->getFieldValue($componentId, 'layout', 'image_position', 'top');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="postGridImagePosition" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-aspect-ratio me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Posicion de imagen';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridImagePosition" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="top"' . ($imagePosition === 'top' ? ' selected' : '') . '>Arriba</option>';
|
||||
$html .= ' <option value="left"' . ($imagePosition === 'left' ? ' selected' : '') . '>Izquierda</option>';
|
||||
$html .= ' <option value="none"' . ($imagePosition === 'none' ? ' selected' : '') . '>Sin imagen</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMediaGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-image me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Medios';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Fallback image
|
||||
$fallbackImage = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image', '');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridFallbackImage" class="form-label small mb-1 fw-semibold">URL imagen por defecto</label>';
|
||||
$html .= ' <input type="url" id="postGridFallbackImage" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_url($fallbackImage) . '" placeholder="https://...">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Fallback image alt
|
||||
$fallbackImageAlt = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image_alt', 'Imagen por defecto');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="postGridFallbackImageAlt" class="form-label small mb-1 fw-semibold">Texto alternativo</label>';
|
||||
$html .= ' <input type="text" id="postGridFallbackImageAlt" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($fallbackImageAlt) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTypographyGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Tipografia';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Heading level
|
||||
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h3');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
|
||||
$html .= ' <select id="postGridHeadingLevel" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="h2"' . ($headingLevel === 'h2' ? ' selected' : '') . '>H2</option>';
|
||||
$html .= ' <option value="h3"' . ($headingLevel === 'h3' ? ' selected' : '') . '>H3</option>';
|
||||
$html .= ' <option value="h4"' . ($headingLevel === 'h4' ? ' selected' : '') . '>H4</option>';
|
||||
$html .= ' <option value="h5"' . ($headingLevel === 'h5' ? ' selected' : '') . '>H5</option>';
|
||||
$html .= ' <option value="h6"' . ($headingLevel === 'h6' ? ' selected' : '') . '>H6</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardTitleSize = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_size', '1.1rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
|
||||
$html .= ' <input type="text" id="postGridCardTitleSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardTitleSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$cardTitleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_weight', '600');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
|
||||
$html .= ' <input type="text" id="postGridCardTitleWeight" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardTitleWeight) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$excerptSize = $this->renderer->getFieldValue($componentId, 'typography', 'excerpt_size', '0.9rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridExcerptSize" class="form-label small mb-1 fw-semibold">Tamano extracto</label>';
|
||||
$html .= ' <input type="text" id="postGridExcerptSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($excerptSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$metaSize = $this->renderer->getFieldValue($componentId, 'typography', 'meta_size', '0.8rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridMetaSize" class="form-label small mb-1 fw-semibold">Tamano metadatos</label>';
|
||||
$html .= ' <input type="text" id="postGridMetaSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($metaSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildColorsGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Colores';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Cards
|
||||
$html .= ' <p class="small fw-semibold mb-2">Cards</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_bg_color', '#ffffff');
|
||||
$html .= $this->buildColorPicker('postGridCardBgColor', 'Fondo', $cardBgColor);
|
||||
|
||||
$cardTitleColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_title_color', '#0E2337');
|
||||
$html .= $this->buildColorPicker('postGridCardTitleColor', 'Titulo', $cardTitleColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardHoverBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_bg_color', '#f9fafb');
|
||||
$html .= $this->buildColorPicker('postGridCardHoverBgColor', 'Fondo hover', $cardHoverBgColor);
|
||||
|
||||
$cardBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_border_color', '#e5e7eb');
|
||||
$html .= $this->buildColorPicker('postGridCardBorderColor', 'Borde', $cardBorderColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardHoverBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_border_color', '#FF8600');
|
||||
$html .= $this->buildColorPicker('postGridCardHoverBorderColor', 'Borde hover', $cardHoverBorderColor);
|
||||
|
||||
$excerptColor = $this->renderer->getFieldValue($componentId, 'colors', 'excerpt_color', '#6b7280');
|
||||
$html .= $this->buildColorPicker('postGridExcerptColor', 'Extracto', $excerptColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$metaColor = $this->renderer->getFieldValue($componentId, 'colors', 'meta_color', '#9ca3af');
|
||||
$html .= $this->buildColorPicker('postGridMetaColor', 'Metadatos', $metaColor);
|
||||
|
||||
$categoryBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_bg_color', '#FFF5EB');
|
||||
$html .= $this->buildColorPicker('postGridCategoryBgColor', 'Fondo cat.', $categoryBgColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$categoryTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_text_color', '#FF8600');
|
||||
$html .= $this->buildColorPicker('postGridCategoryTextColor', 'Texto cat.', $categoryTextColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Paginacion
|
||||
$html .= ' <p class="small fw-semibold mb-2">Paginacion</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$paginationColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_color', '#0E2337');
|
||||
$html .= $this->buildColorPicker('postGridPaginationColor', 'Color', $paginationColor);
|
||||
|
||||
$paginationActiveBg = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_bg', '#FF8600');
|
||||
$html .= $this->buildColorPicker('postGridPaginationActiveBg', 'Activo fondo', $paginationActiveBg);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$paginationActiveColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_color', '#ffffff');
|
||||
$html .= $this->buildColorPicker('postGridPaginationActiveColor', 'Activo texto', $paginationActiveColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSpacingGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Espaciado';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Separación entre cards
|
||||
$html .= ' <p class="small text-muted mb-2">Separacion entre cards:</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
// Gap horizontal (entre columnas)
|
||||
$gapHorizontal = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_horizontal', '24px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridGapHorizontal" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>Horizontal';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridGapHorizontal" class="form-select form-select-sm">';
|
||||
$gapOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px', '40px', '48px'];
|
||||
foreach ($gapOptions as $opt) {
|
||||
$selected = ($gapHorizontal === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Gap vertical (entre filas)
|
||||
$gapVertical = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_vertical', '24px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridGapVertical" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrows-collapse me-1" style="color: #FF8600;"></i>Vertical';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridGapVertical" class="form-select form-select-sm">';
|
||||
foreach ($gapOptions as $opt) {
|
||||
$selected = ($gapVertical === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Padding interno de cada card
|
||||
$html .= ' <p class="small text-muted mb-2">Padding interno de card:</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '20px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-box me-1" style="color: #FF8600;"></i>Padding';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridCardPadding" class="form-select form-select-sm">';
|
||||
$paddingOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px'];
|
||||
foreach ($paddingOptions as $opt) {
|
||||
$selected = ($cardPadding === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-6"></div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Margenes de la seccion
|
||||
$html .= ' <p class="small text-muted mb-2">Margenes de la seccion:</p>';
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrow-up me-1" style="color: #FF8600;"></i>Arriba';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridSectionMarginTop" class="form-select form-select-sm">';
|
||||
$marginOptions = ['0px', '8px', '16px', '24px', '32px', '48px', '64px'];
|
||||
foreach ($marginOptions as $opt) {
|
||||
$selected = ($sectionMarginTop === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '32px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrow-down me-1" style="color: #FF8600;"></i>Abajo';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridSectionMarginBottom" class="form-select form-select-sm">';
|
||||
foreach ($marginOptions as $opt) {
|
||||
$selected = ($sectionMarginBottom === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildEffectsGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Efectos Visuales';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_border_radius', '0.5rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardBorderRadius" class="form-label small mb-1 fw-semibold">Radio borde</label>';
|
||||
$html .= ' <input type="text" id="postGridCardBorderRadius" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardBorderRadius) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$imageBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'image_border_radius', '0.375rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridImageBorderRadius" class="form-label small mb-1 fw-semibold">Radio imagen</label>';
|
||||
$html .= ' <input type="text" id="postGridImageBorderRadius" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($imageBorderRadius) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-3">';
|
||||
$cardTransition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_transition', 'all 0.3s ease');
|
||||
$html .= ' <label for="postGridCardTransition" class="form-label small mb-1 fw-semibold">Transicion</label>';
|
||||
$html .= ' <input type="text" id="postGridCardTransition" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardTransition) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-3">';
|
||||
$cardShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_shadow', '0 1px 3px rgba(0,0,0,0.1)');
|
||||
$html .= ' <label for="postGridCardShadow" class="form-label small mb-1 fw-semibold">Sombra normal</label>';
|
||||
$html .= ' <input type="text" id="postGridCardShadow" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardShadow) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-0">';
|
||||
$cardHoverShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_hover_shadow', '0 4px 12px rgba(0,0,0,0.15)');
|
||||
$html .= ' <label for="postGridCardHoverShadow" class="form-label small mb-1 fw-semibold">Sombra hover</label>';
|
||||
$html .= ' <input type="text" id="postGridCardHoverShadow" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardHoverShadow) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
|
||||
{
|
||||
$checked = $checked === true || $checked === '1' || $checked === 1;
|
||||
|
||||
$html = ' <div class="mb-2">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= sprintf(
|
||||
' <input class="form-check-input" type="checkbox" id="%s" %s>',
|
||||
esc_attr($id),
|
||||
$checked ? 'checked' : ''
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <label class="form-check-label small" for="%s">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
|
||||
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildColorPicker(string $id, string $label, string $value): string
|
||||
{
|
||||
$html = ' <div class="col-6">';
|
||||
$html .= sprintf(
|
||||
' <label class="form-label small fw-semibold">%s</label>',
|
||||
esc_html($label)
|
||||
);
|
||||
$html .= ' <div class="input-group input-group-sm">';
|
||||
$html .= sprintf(
|
||||
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
|
||||
esc_attr($id),
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <span class="input-group-text" id="%sValue">%s</span>',
|
||||
esc_attr($id),
|
||||
esc_html(strtoupper($value))
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
|
||||
{
|
||||
$checked = $checked === true || $checked === '1' || $checked === 1;
|
||||
|
||||
$html = ' <div class="form-check form-check-checkbox mb-2">';
|
||||
$html .= sprintf(
|
||||
' <input class="form-check-input" type="checkbox" id="%s" %s>',
|
||||
esc_attr($id),
|
||||
$checked ? 'checked' : ''
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <label class="form-check-label small" for="%s">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
|
||||
$html .= sprintf(' %s', esc_html($label));
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildShortcodeGuide(): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-code-square me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Shortcode [roi_post_grid]';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <p class="small text-muted mb-3">';
|
||||
$html .= ' Usa este shortcode para insertar grids de posts en cualquier pagina o entrada. ';
|
||||
$html .= ' Los estilos se heredan de la configuracion de este componente.';
|
||||
$html .= ' </p>';
|
||||
|
||||
// Uso basico
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-1-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Uso basico (9 posts, 3 columnas)';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Por categoria
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-2-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Filtrar por categoria';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid category="precios-unitarios"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Personalizar cantidad y columnas
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-3-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' 6 posts en 2 columnas';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="6" columns="2"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Con paginacion
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-4-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Con paginacion';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="12" show_pagination="true"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Filtrar por tag
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-5-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Filtrar por etiqueta';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid tag="tutorial"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Ejemplo completo
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-6-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Ejemplo completo';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.75rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid category="cursos" posts_per_page="6" columns="3" show_meta="false" show_categories="true"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Tabla de atributos
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-list-check me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Atributos disponibles';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="table-responsive">';
|
||||
$html .= ' <table class="table table-sm table-bordered small mb-0">';
|
||||
$html .= ' <thead class="table-light">';
|
||||
$html .= ' <tr><th>Atributo</th><th>Default</th><th>Descripcion</th></tr>';
|
||||
$html .= ' </thead>';
|
||||
$html .= ' <tbody>';
|
||||
$html .= ' <tr><td><code>posts_per_page</code></td><td>9</td><td>Cantidad de posts</td></tr>';
|
||||
$html .= ' <tr><td><code>columns</code></td><td>3</td><td>Columnas (1-4)</td></tr>';
|
||||
$html .= ' <tr><td><code>category</code></td><td>-</td><td>Slug de categoria</td></tr>';
|
||||
$html .= ' <tr><td><code>exclude_category</code></td><td>-</td><td>Excluir categoria</td></tr>';
|
||||
$html .= ' <tr><td><code>tag</code></td><td>-</td><td>Slug de etiqueta</td></tr>';
|
||||
$html .= ' <tr><td><code>author</code></td><td>-</td><td>ID o username</td></tr>';
|
||||
$html .= ' <tr><td><code>orderby</code></td><td>date</td><td>date, title, rand</td></tr>';
|
||||
$html .= ' <tr><td><code>order</code></td><td>DESC</td><td>DESC o ASC</td></tr>';
|
||||
$html .= ' <tr><td><code>show_pagination</code></td><td>false</td><td>Mostrar paginacion</td></tr>';
|
||||
$html .= ' <tr><td><code>show_thumbnail</code></td><td>true</td><td>Mostrar imagen</td></tr>';
|
||||
$html .= ' <tr><td><code>show_excerpt</code></td><td>true</td><td>Mostrar extracto</td></tr>';
|
||||
$html .= ' <tr><td><code>show_meta</code></td><td>true</td><td>Fecha y autor</td></tr>';
|
||||
$html .= ' <tr><td><code>show_categories</code></td><td>true</td><td>Badges categoria</td></tr>';
|
||||
$html .= ' <tr><td><code>excerpt_length</code></td><td>20</td><td>Palabras extracto</td></tr>';
|
||||
$html .= ' <tr><td><code>exclude_posts</code></td><td>-</td><td>IDs separados por coma</td></tr>';
|
||||
$html .= ' <tr><td><code>offset</code></td><td>0</td><td>Saltar N posts</td></tr>';
|
||||
$html .= ' <tr><td><code>id</code></td><td>-</td><td>ID unico (multiples grids)</td></tr>';
|
||||
$html .= ' <tr><td><code>class</code></td><td>-</td><td>Clase CSS adicional</td></tr>';
|
||||
$html .= ' </tbody>';
|
||||
$html .= ' </table>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ final class FieldMapperProvider
|
||||
'Footer',
|
||||
'ThemeSettings',
|
||||
'AdsensePlacement',
|
||||
'ArchiveHeader',
|
||||
'PostGrid',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
BASE STYLES - Todas las tablas genéricas
|
||||
======================================== */
|
||||
|
||||
.post-content table:not(.analisis table) {
|
||||
.post-content table:not(.analisis table):not(.desglose table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem auto;
|
||||
@@ -23,9 +23,9 @@
|
||||
}
|
||||
|
||||
/* Header styles - VERY OBVIOUS */
|
||||
.post-content table:not(.analisis table) thead tr:first-child th,
|
||||
.post-content table:not(.analisis table) tbody tr:first-child td,
|
||||
.post-content table:not(.analisis table) tr:first-child td {
|
||||
.post-content table:not(.analisis table):not(.desglose table) thead tr:first-child th,
|
||||
.post-content table:not(.analisis table):not(.desglose table) tbody tr:first-child td,
|
||||
.post-content table:not(.analisis table):not(.desglose table) tr:first-child td {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 1.25rem 1rem;
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
/* Body cells */
|
||||
.post-content table:not(.analisis table) tbody tr:not(:first-child) td {
|
||||
.post-content table:not(.analisis table):not(.desglose table) tbody tr:not(:first-child) td {
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--color-neutral-100);
|
||||
text-align: left;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Auto-detectar y agregar clases a filas especiales de tablas APU
|
||||
*
|
||||
* Este script detecta automáticamente filas especiales en tablas .desglose y .analisis
|
||||
* y les agrega las clases CSS correspondientes para que se apliquen los estilos correctos.
|
||||
*
|
||||
* Detecta:
|
||||
* - Section headers: Material, Mano de Obra, Herramienta, Equipo
|
||||
* - Subtotal rows: Filas que empiezan con "Suma de"
|
||||
* - Total row: Costo Directo
|
||||
*
|
||||
* @package Apus_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Agrega clases a filas especiales de tablas APU
|
||||
*/
|
||||
function applyApuTableClasses() {
|
||||
// Buscar todas las tablas con clase .desglose o .analisis
|
||||
const tables = document.querySelectorAll('.desglose table, .analisis table');
|
||||
|
||||
if (tables.length === 0) {
|
||||
return; // No hay tablas APU en esta página
|
||||
}
|
||||
|
||||
let classesAdded = 0;
|
||||
|
||||
tables.forEach(function(table) {
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(function(row) {
|
||||
// Evitar procesar filas que ya tienen clase
|
||||
if (row.classList.contains('section-header') ||
|
||||
row.classList.contains('subtotal-row') ||
|
||||
row.classList.contains('total-row')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secondCell = row.querySelector('td:nth-child(2)');
|
||||
if (!secondCell) {
|
||||
return; // Fila sin segunda celda
|
||||
}
|
||||
|
||||
const text = secondCell.textContent.trim();
|
||||
|
||||
// Detectar section headers
|
||||
if (text === 'Material' ||
|
||||
text === 'Mano de Obra' ||
|
||||
text === 'Herramienta' ||
|
||||
text === 'Equipo' ||
|
||||
text === 'MATERIAL' ||
|
||||
text === 'MANO DE OBRA' ||
|
||||
text === 'HERRAMIENTA' ||
|
||||
text === 'EQUIPO') {
|
||||
row.classList.add('section-header');
|
||||
classesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detectar subtotales (cualquier variación de "Suma de")
|
||||
if (text.toLowerCase().startsWith('suma de ') ||
|
||||
text.toLowerCase().startsWith('subtotal ')) {
|
||||
row.classList.add('subtotal-row');
|
||||
classesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detectar total final
|
||||
if (text === 'Costo Directo' ||
|
||||
text === 'COSTO DIRECTO' ||
|
||||
text === 'Total' ||
|
||||
text === 'TOTAL' ||
|
||||
text === 'Costo directo') {
|
||||
row.classList.add('total-row');
|
||||
classesAdded++;
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Log para debugging (solo en desarrollo)
|
||||
if (classesAdded > 0 && window.console) {
|
||||
console.log('[APU Tables] Clases agregadas automáticamente: ' + classesAdded);
|
||||
}
|
||||
}
|
||||
|
||||
// Ejecutar cuando el DOM esté listo
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', applyApuTableClasses);
|
||||
} else {
|
||||
// DOM ya está listo
|
||||
applyApuTableClasses();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,342 +0,0 @@
|
||||
/**
|
||||
* Header Navigation JavaScript
|
||||
*
|
||||
* This file handles:
|
||||
* - Mobile hamburger menu toggle
|
||||
* - Sticky header behavior
|
||||
* - Smooth scroll to anchors (optional)
|
||||
* - Accessibility features (keyboard navigation, ARIA attributes)
|
||||
* - Body scroll locking when mobile menu is open
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Initialize on DOM ready
|
||||
*/
|
||||
function init() {
|
||||
setupMobileMenu();
|
||||
setupStickyHeader();
|
||||
setupSmoothScroll();
|
||||
setupKeyboardNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile Menu Functionality
|
||||
*/
|
||||
function setupMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
const mobileMenuClose = document.getElementById('mobile-menu-close');
|
||||
|
||||
if (!mobileMenuToggle || !mobileMenu || !mobileMenuOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open mobile menu
|
||||
mobileMenuToggle.addEventListener('click', function() {
|
||||
openMobileMenu();
|
||||
});
|
||||
|
||||
// Close mobile menu via close button
|
||||
if (mobileMenuClose) {
|
||||
mobileMenuClose.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// Close mobile menu via overlay click
|
||||
mobileMenuOverlay.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
|
||||
// Close mobile menu on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobileMenu.classList.contains('active')) {
|
||||
closeMobileMenu();
|
||||
mobileMenuToggle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking a menu link
|
||||
const mobileMenuLinks = mobileMenu.querySelectorAll('a');
|
||||
mobileMenuLinks.forEach(function(link) {
|
||||
link.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle window resize - close mobile menu if switching to desktop
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', function() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function() {
|
||||
if (window.innerWidth >= 768 && mobileMenu.classList.contains('active')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open mobile menu
|
||||
*/
|
||||
function openMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
|
||||
// Add active classes
|
||||
mobileMenu.classList.add('active');
|
||||
mobileMenuOverlay.classList.add('active');
|
||||
document.body.classList.add('mobile-menu-open');
|
||||
|
||||
// Update ARIA attributes
|
||||
mobileMenuToggle.setAttribute('aria-expanded', 'true');
|
||||
mobileMenu.setAttribute('aria-hidden', 'false');
|
||||
mobileMenuOverlay.setAttribute('aria-hidden', 'false');
|
||||
|
||||
// Focus trap - focus first menu item
|
||||
const firstMenuItem = mobileMenu.querySelector('a');
|
||||
if (firstMenuItem) {
|
||||
setTimeout(function() {
|
||||
firstMenuItem.focus();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close mobile menu
|
||||
*/
|
||||
function closeMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
|
||||
// Remove active classes
|
||||
mobileMenu.classList.remove('active');
|
||||
mobileMenuOverlay.classList.remove('active');
|
||||
document.body.classList.remove('mobile-menu-open');
|
||||
|
||||
// Update ARIA attributes
|
||||
mobileMenuToggle.setAttribute('aria-expanded', 'false');
|
||||
mobileMenu.setAttribute('aria-hidden', 'true');
|
||||
mobileMenuOverlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Header Behavior
|
||||
*/
|
||||
function setupStickyHeader() {
|
||||
const header = document.getElementById('masthead');
|
||||
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastScrollTop = 0;
|
||||
let scrollThreshold = 100;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
// Add/remove scrolled class based on scroll position
|
||||
if (scrollTop > scrollThreshold) {
|
||||
header.classList.add('scrolled');
|
||||
} else {
|
||||
header.classList.remove('scrolled');
|
||||
}
|
||||
|
||||
lastScrollTop = scrollTop;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth Scroll to Anchors (Optional)
|
||||
*/
|
||||
function setupSmoothScroll() {
|
||||
// Check if user prefers reduced motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all anchor links
|
||||
const anchorLinks = document.querySelectorAll('a[href^="#"]');
|
||||
|
||||
anchorLinks.forEach(function(link) {
|
||||
link.addEventListener('click', function(e) {
|
||||
const href = this.getAttribute('href');
|
||||
|
||||
// Skip if href is just "#"
|
||||
if (href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.querySelector(href);
|
||||
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get header height for offset
|
||||
const header = document.getElementById('masthead');
|
||||
const headerHeight = header ? header.offsetHeight : 0;
|
||||
const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - headerHeight - 20;
|
||||
|
||||
window.scrollTo({
|
||||
top: targetPosition,
|
||||
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
||||
});
|
||||
|
||||
// Update URL hash
|
||||
if (history.pushState) {
|
||||
history.pushState(null, null, href);
|
||||
}
|
||||
|
||||
// Focus target element for accessibility
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard Navigation for Menus
|
||||
*/
|
||||
function setupKeyboardNavigation() {
|
||||
const menuItems = document.querySelectorAll('.primary-menu > li, .mobile-primary-menu > li');
|
||||
|
||||
menuItems.forEach(function(item) {
|
||||
const link = item.querySelector('a');
|
||||
const submenu = item.querySelector('.sub-menu');
|
||||
|
||||
if (!link || !submenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open submenu on Enter/Space
|
||||
link.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (submenu) {
|
||||
e.preventDefault();
|
||||
toggleSubmenu(item, submenu);
|
||||
}
|
||||
}
|
||||
|
||||
// Close submenu on Escape
|
||||
if (e.key === 'Escape') {
|
||||
closeSubmenu(item, submenu);
|
||||
link.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Close submenu when focus leaves
|
||||
const submenuLinks = submenu.querySelectorAll('a');
|
||||
if (submenuLinks.length > 0) {
|
||||
const lastSubmenuLink = submenuLinks[submenuLinks.length - 1];
|
||||
|
||||
lastSubmenuLink.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
closeSubmenu(item, submenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle submenu visibility
|
||||
*/
|
||||
function toggleSubmenu(item, submenu) {
|
||||
const isExpanded = item.classList.contains('submenu-open');
|
||||
|
||||
if (isExpanded) {
|
||||
closeSubmenu(item, submenu);
|
||||
} else {
|
||||
openSubmenu(item, submenu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open submenu
|
||||
*/
|
||||
function openSubmenu(item, submenu) {
|
||||
item.classList.add('submenu-open');
|
||||
submenu.setAttribute('aria-hidden', 'false');
|
||||
|
||||
const firstLink = submenu.querySelector('a');
|
||||
if (firstLink) {
|
||||
firstLink.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close submenu
|
||||
*/
|
||||
function closeSubmenu(item, submenu) {
|
||||
item.classList.remove('submenu-open');
|
||||
submenu.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap focus within mobile menu when open
|
||||
*/
|
||||
function setupFocusTrap() {
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (!mobileMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (!mobileMenu.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = mobileMenu.querySelectorAll(
|
||||
'a, button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize focus trap
|
||||
*/
|
||||
setupFocusTrap();
|
||||
|
||||
/**
|
||||
* Initialize when DOM is ready
|
||||
*/
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,294 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Related Posts Functionality
|
||||
*
|
||||
* Provides configurable related posts functionality with Bootstrap grid support.
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Exit if accessed directly
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts based on categories
|
||||
*
|
||||
* @param int $post_id The post ID to get related posts for
|
||||
* @return WP_Query|false Query object with related posts or false if none found
|
||||
*/
|
||||
function roi_get_related_posts($post_id) {
|
||||
// Get post categories
|
||||
$categories = wp_get_post_categories($post_id);
|
||||
|
||||
if (empty($categories)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get number of posts to display (default: 3)
|
||||
$posts_per_page = get_option('roi_related_posts_count', 3);
|
||||
|
||||
// Query arguments
|
||||
$args = array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $posts_per_page,
|
||||
'post__not_in' => array($post_id),
|
||||
'category__in' => $categories,
|
||||
'orderby' => 'rand',
|
||||
'no_found_rows' => true,
|
||||
'update_post_meta_cache' => false,
|
||||
'update_post_term_cache' => false,
|
||||
);
|
||||
|
||||
// Allow filtering of query args
|
||||
$args = apply_filters('roi_related_posts_args', $args, $post_id);
|
||||
|
||||
// Get related posts
|
||||
$related_query = new WP_Query($args);
|
||||
|
||||
return $related_query->have_posts() ? $related_query : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display related posts section
|
||||
*
|
||||
* @param int|null $post_id Optional. Post ID. Default is current post.
|
||||
* @return void
|
||||
*/
|
||||
function roi_display_related_posts($post_id = null) {
|
||||
// Get post ID
|
||||
if (!$post_id) {
|
||||
$post_id = get_the_ID();
|
||||
}
|
||||
|
||||
// Check if related posts are enabled
|
||||
$enabled = get_option('roi_related_posts_enabled', true);
|
||||
if (!$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get related posts
|
||||
$related_query = roi_get_related_posts($post_id);
|
||||
|
||||
if (!$related_query) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get configuration options
|
||||
$title = get_option('roi_related_posts_title', __('Related Posts', 'roi-theme'));
|
||||
$columns = get_option('roi_related_posts_columns', 3);
|
||||
$show_excerpt = get_option('roi_related_posts_show_excerpt', true);
|
||||
$show_date = get_option('roi_related_posts_show_date', true);
|
||||
$show_category = get_option('roi_related_posts_show_category', true);
|
||||
$excerpt_length = get_option('roi_related_posts_excerpt_length', 20);
|
||||
$background_colors = get_option('roi_related_posts_bg_colors', array(
|
||||
'#1a73e8', // Blue
|
||||
'#e91e63', // Pink
|
||||
'#4caf50', // Green
|
||||
'#ff9800', // Orange
|
||||
'#9c27b0', // Purple
|
||||
'#00bcd4', // Cyan
|
||||
));
|
||||
|
||||
// Calculate Bootstrap column class
|
||||
$col_class = roi_get_column_class($columns);
|
||||
|
||||
// Start output
|
||||
?>
|
||||
<section class="related-posts-section">
|
||||
<div class="related-posts-container">
|
||||
|
||||
<?php if ($title) : ?>
|
||||
<h2 class="related-posts-title"><?php echo esc_html($title); ?></h2>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<?php
|
||||
$color_index = 0;
|
||||
while ($related_query->have_posts()) :
|
||||
$related_query->the_post();
|
||||
$has_thumbnail = has_post_thumbnail();
|
||||
|
||||
// Get background color for posts without image
|
||||
$bg_color = $background_colors[$color_index % count($background_colors)];
|
||||
$color_index++;
|
||||
?>
|
||||
|
||||
<div class="<?php echo esc_attr($col_class); ?>">
|
||||
<article class="related-post-card <?php echo $has_thumbnail ? 'has-thumbnail' : 'no-thumbnail'; ?>">
|
||||
|
||||
<a href="<?php the_permalink(); ?>" class="related-post-link">
|
||||
|
||||
<?php if ($has_thumbnail) : ?>
|
||||
<!-- Card with Image -->
|
||||
<div class="related-post-thumbnail">
|
||||
<?php
|
||||
the_post_thumbnail('roi-thumbnail', array(
|
||||
'alt' => the_title_attribute(array('echo' => false)),
|
||||
'loading' => 'lazy',
|
||||
));
|
||||
?>
|
||||
|
||||
<?php if ($show_category) : ?>
|
||||
<?php
|
||||
$categories = get_the_category();
|
||||
if (!empty($categories)) :
|
||||
$category = $categories[0];
|
||||
?>
|
||||
<span class="related-post-category">
|
||||
<?php echo esc_html($category->name); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<!-- Card without Image - Color Background -->
|
||||
<div class="related-post-no-image" style="background-color: <?php echo esc_attr($bg_color); ?>;">
|
||||
<div class="related-post-no-image-content">
|
||||
<h3 class="related-post-no-image-title">
|
||||
<?php the_title(); ?>
|
||||
</h3>
|
||||
|
||||
<?php if ($show_category) : ?>
|
||||
<?php
|
||||
$categories = get_the_category();
|
||||
if (!empty($categories)) :
|
||||
$category = $categories[0];
|
||||
?>
|
||||
<span class="related-post-category no-image">
|
||||
<?php echo esc_html($category->name); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="related-post-content">
|
||||
|
||||
<?php if ($has_thumbnail) : ?>
|
||||
<h3 class="related-post-title">
|
||||
<?php the_title(); ?>
|
||||
</h3>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($show_excerpt && $excerpt_length > 0) : ?>
|
||||
<div class="related-post-excerpt">
|
||||
<?php echo wp_trim_words(get_the_excerpt(), $excerpt_length, '...'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($show_date) : ?>
|
||||
<div class="related-post-meta">
|
||||
<time class="related-post-date" datetime="<?php echo esc_attr(get_the_date('c')); ?>">
|
||||
<?php echo esc_html(get_the_date()); ?>
|
||||
</time>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<?php endwhile; ?>
|
||||
</div><!-- .row -->
|
||||
|
||||
</div><!-- .related-posts-container -->
|
||||
</section><!-- .related-posts-section -->
|
||||
|
||||
<?php
|
||||
// Reset post data
|
||||
wp_reset_postdata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bootstrap column class based on number of columns
|
||||
*
|
||||
* @param int $columns Number of columns (1-4)
|
||||
* @return string Bootstrap column classes
|
||||
*/
|
||||
function roi_get_column_class($columns) {
|
||||
$columns = absint($columns);
|
||||
|
||||
switch ($columns) {
|
||||
case 1:
|
||||
return 'col-12';
|
||||
case 2:
|
||||
return 'col-12 col-md-6';
|
||||
case 3:
|
||||
return 'col-12 col-sm-6 col-lg-4';
|
||||
case 4:
|
||||
return 'col-12 col-sm-6 col-lg-3';
|
||||
default:
|
||||
return 'col-12 col-sm-6 col-lg-4'; // Default to 3 columns
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook related posts display after post content
|
||||
*/
|
||||
function roi_hook_related_posts() {
|
||||
if (is_single() && !is_attachment()) {
|
||||
roi_display_related_posts();
|
||||
}
|
||||
}
|
||||
add_action('roi_after_post_content', 'roi_hook_related_posts');
|
||||
|
||||
/**
|
||||
* Enqueue related posts styles
|
||||
*/
|
||||
function roi_enqueue_related_posts_styles() {
|
||||
if (is_single() && !is_attachment()) {
|
||||
$enabled = get_option('roi_related_posts_enabled', true);
|
||||
|
||||
if ($enabled) {
|
||||
wp_enqueue_style(
|
||||
'roirelated-posts',
|
||||
get_template_directory_uri() . '/Assets/Css/related-posts.css',
|
||||
array('roibootstrap'),
|
||||
ROI_VERSION,
|
||||
'all'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
add_action('wp_enqueue_scripts', 'roi_enqueue_related_posts_styles');
|
||||
|
||||
/**
|
||||
* Register related posts settings
|
||||
* These can be configured via theme options or customizer
|
||||
*/
|
||||
function roi_related_posts_default_options() {
|
||||
// Set default options if they don't exist
|
||||
$defaults = array(
|
||||
'roi_related_posts_enabled' => true,
|
||||
'roi_related_posts_title' => __('Related Posts', 'roi-theme'),
|
||||
'roi_related_posts_count' => 3,
|
||||
'roi_related_posts_columns' => 3,
|
||||
'roi_related_posts_show_excerpt' => true,
|
||||
'roi_related_posts_excerpt_length' => 20,
|
||||
'roi_related_posts_show_date' => true,
|
||||
'roi_related_posts_show_category' => true,
|
||||
'roi_related_posts_bg_colors' => array(
|
||||
'#1a73e8', // Blue
|
||||
'#e91e63', // Pink
|
||||
'#4caf50', // Green
|
||||
'#ff9800', // Orange
|
||||
'#9c27b0', // Purple
|
||||
'#00bcd4', // Cyan
|
||||
),
|
||||
);
|
||||
|
||||
foreach ($defaults as $option => $value) {
|
||||
if (get_option($option) === false) {
|
||||
add_option($option, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
add_action('after_setup_theme', 'roi_related_posts_default_options');
|
||||
314
Public/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderRenderer.php
Normal file
314
Public/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderRenderer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
651
Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
Normal file
651
Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
Normal file
@@ -0,0 +1,651 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\PostGrid\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
|
||||
/**
|
||||
* PostGridRenderer - Renderiza grid de posts del loop principal de WordPress
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML y CSS del componente Post Grid
|
||||
*
|
||||
* DIFERENCIA CON RelatedPostRenderer:
|
||||
* - PostGrid usa global $wp_query (loop principal)
|
||||
* - RelatedPost crea su propio WP_Query
|
||||
*
|
||||
* CARACTERISTICAS:
|
||||
* - Grid responsive de cards con imagen, excerpt y meta
|
||||
* - Usa loop principal de WordPress (no crea queries propias)
|
||||
* - Paginacion nativa de WordPress
|
||||
* - Estilos 100% desde BD via CSSGenerator
|
||||
*
|
||||
* @package ROITheme\Public\PostGrid\Infrastructure\Ui
|
||||
*/
|
||||
final class PostGridRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'post-grid';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
|
||||
public function render(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
|
||||
if (!$this->isEnabled($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$visibilityClass = $this->getVisibilityClass($data);
|
||||
if ($visibilityClass === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
global $wp_query;
|
||||
|
||||
// Si no hay posts, mostrar mensaje
|
||||
if (!have_posts()) {
|
||||
$noPostsMessage = $data['content']['no_posts_message'] ?? 'No se encontraron publicaciones';
|
||||
return $this->renderNoPostsMessage($noPostsMessage, $visibilityClass, $data);
|
||||
}
|
||||
|
||||
$css = $this->generateCSS($data);
|
||||
$html = $this->buildHTML($data, $visibilityClass);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
{
|
||||
$value = $data['visibility']['is_enabled'] ?? false;
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function getVisibilityClass(array $data): ?string
|
||||
{
|
||||
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
|
||||
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
|
||||
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
|
||||
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
|
||||
|
||||
if (!$showDesktop && !$showMobile) {
|
||||
return null;
|
||||
}
|
||||
if (!$showDesktop && $showMobile) {
|
||||
return 'd-lg-none';
|
||||
}
|
||||
if ($showDesktop && !$showMobile) {
|
||||
return 'd-none d-lg-block';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function renderNoPostsMessage(string $message, string $visibilityClass, array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
$spacing = $data['spacing'] ?? [];
|
||||
|
||||
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$textColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
$padding = $spacing['card_padding'] ?? '1.25rem';
|
||||
|
||||
$css = $this->cssGenerator->generate('.post-grid-no-posts', [
|
||||
'background-color' => $bgColor,
|
||||
'color' => $textColor,
|
||||
'border' => "1px solid {$borderColor}",
|
||||
'border-radius' => '0.5rem',
|
||||
'padding' => '2rem',
|
||||
'text-align' => 'center',
|
||||
]);
|
||||
|
||||
$containerClass = 'post-grid-no-posts';
|
||||
if (!empty($visibilityClass)) {
|
||||
$containerClass .= ' ' . $visibilityClass;
|
||||
}
|
||||
|
||||
$html = sprintf(
|
||||
'<div class="%s"><p class="mb-0">%s</p></div>',
|
||||
esc_attr($containerClass),
|
||||
esc_html($message)
|
||||
);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
$spacing = $data['spacing'] ?? [];
|
||||
$effects = $data['visual_effects'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
$layout = $data['layout'] ?? [];
|
||||
|
||||
$cssRules = [];
|
||||
|
||||
// Colores
|
||||
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
|
||||
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
|
||||
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
|
||||
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$metaColor = $colors['meta_color'] ?? '#9ca3af';
|
||||
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
|
||||
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
|
||||
$paginationColor = $colors['pagination_color'] ?? '#0E2337';
|
||||
$paginationActiveBg = $colors['pagination_active_bg'] ?? '#FF8600';
|
||||
$paginationActiveColor = $colors['pagination_active_color'] ?? '#ffffff';
|
||||
|
||||
// Spacing
|
||||
$gapHorizontal = $spacing['gap_horizontal'] ?? '24px';
|
||||
$gapVertical = $spacing['gap_vertical'] ?? '24px';
|
||||
$cardPadding = $spacing['card_padding'] ?? '20px';
|
||||
$sectionMarginTop = $spacing['section_margin_top'] ?? '0px';
|
||||
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '32px';
|
||||
|
||||
// Visual effects
|
||||
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
|
||||
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
|
||||
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
|
||||
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
|
||||
$imageBorderRadius = $effects['image_border_radius'] ?? '0.375rem';
|
||||
|
||||
// Typography
|
||||
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
|
||||
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
|
||||
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
|
||||
$metaSize = $typography['meta_size'] ?? '0.8rem';
|
||||
|
||||
// Container
|
||||
$cssRules[] = $this->cssGenerator->generate('.post-grid', [
|
||||
'margin-top' => $sectionMarginTop,
|
||||
'margin-bottom' => $sectionMarginBottom,
|
||||
]);
|
||||
|
||||
// Row: usar display flex con gap, quitar margins/paddings de Bootstrap
|
||||
$cssRules[] = ".post-grid .row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: {$gapHorizontal};
|
||||
row-gap: {$gapVertical};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}";
|
||||
|
||||
// Columnas: quitar padding de Bootstrap y margin-bottom
|
||||
$cssRules[] = ".post-grid .post-card-col {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}";
|
||||
|
||||
// Card base - sin margin extra
|
||||
$cssRules[] = ".post-grid .card {
|
||||
background: {$cardBgColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
border-radius: {$cardBorderRadius};
|
||||
box-shadow: {$cardShadow};
|
||||
transition: {$cardTransition};
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}";
|
||||
|
||||
// Card hover
|
||||
$cssRules[] = ".post-grid .card:hover {
|
||||
background: {$cardHoverBgColor};
|
||||
border-color: {$cardHoverBorderColor};
|
||||
box-shadow: {$cardHoverShadow};
|
||||
transform: translateY(-2px);
|
||||
}";
|
||||
|
||||
// Card body
|
||||
$cssRules[] = $this->cssGenerator->generate('.post-grid .card-body', [
|
||||
'padding' => $cardPadding,
|
||||
]);
|
||||
|
||||
// Card image
|
||||
$cssRules[] = ".post-grid .card-img-top {
|
||||
border-radius: {$imageBorderRadius} {$imageBorderRadius} 0 0;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}";
|
||||
|
||||
// Card title
|
||||
$cssRules[] = ".post-grid .card-title {
|
||||
color: {$cardTitleColor};
|
||||
font-size: {$cardTitleSize};
|
||||
font-weight: {$cardTitleWeight};
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.75rem;
|
||||
}";
|
||||
|
||||
// Card title hover
|
||||
$cssRules[] = ".post-grid a:hover .card-title {
|
||||
color: {$cardHoverBorderColor};
|
||||
}";
|
||||
|
||||
// Excerpt
|
||||
$cssRules[] = ".post-grid .card-text {
|
||||
color: {$excerptColor};
|
||||
font-size: {$excerptSize};
|
||||
line-height: 1.6;
|
||||
}";
|
||||
|
||||
// Meta
|
||||
$cssRules[] = ".post-grid .post-meta {
|
||||
color: {$metaColor};
|
||||
font-size: {$metaSize};
|
||||
}";
|
||||
|
||||
// Categories
|
||||
$cssRules[] = ".post-grid .post-category {
|
||||
background: {$categoryBgColor};
|
||||
color: {$categoryTextColor};
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .post-category:hover {
|
||||
background: {$categoryTextColor};
|
||||
color: #ffffff;
|
||||
}";
|
||||
|
||||
// Pagination
|
||||
$cssRules[] = ".post-grid .pagination {
|
||||
margin-top: 2rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .page-link {
|
||||
color: {$paginationColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .page-link:hover {
|
||||
background-color: rgba(255, 133, 0, 0.1);
|
||||
border-color: {$paginationActiveBg};
|
||||
color: {$paginationActiveBg};
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .page-item.active .page-link,
|
||||
.post-grid .nav-links .current {
|
||||
background-color: {$paginationActiveBg};
|
||||
border-color: {$paginationActiveBg};
|
||||
color: {$paginationActiveColor};
|
||||
}";
|
||||
|
||||
// WordPress pagination classes
|
||||
$cssRules[] = ".post-grid .nav-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .nav-links a,
|
||||
.post-grid .nav-links span {
|
||||
color: {$paginationColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .nav-links a:hover {
|
||||
background-color: rgba(255, 133, 0, 0.1);
|
||||
border-color: {$paginationActiveBg};
|
||||
color: {$paginationActiveBg};
|
||||
}";
|
||||
|
||||
// Layout responsive columns
|
||||
$colsDesktop = $layout['columns_desktop'] ?? '3';
|
||||
$colsTablet = $layout['columns_tablet'] ?? '2';
|
||||
$colsMobile = $layout['columns_mobile'] ?? '1';
|
||||
|
||||
// Mobile (1 col = no gap needed)
|
||||
$mobileWidth = $this->getColumnWidth($colsMobile, $gapHorizontal);
|
||||
$cssRules[] = "@media (max-width: 575.98px) {
|
||||
.post-grid .post-card-col {
|
||||
flex: 0 0 {$mobileWidth};
|
||||
max-width: {$mobileWidth};
|
||||
}
|
||||
}";
|
||||
|
||||
// Tablet
|
||||
$tabletWidth = $this->getColumnWidth($colsTablet, $gapHorizontal);
|
||||
$cssRules[] = "@media (min-width: 576px) and (max-width: 991.98px) {
|
||||
.post-grid .post-card-col {
|
||||
flex: 0 0 {$tabletWidth};
|
||||
max-width: {$tabletWidth};
|
||||
}
|
||||
}";
|
||||
|
||||
// Desktop
|
||||
$desktopWidth = $this->getColumnWidth($colsDesktop, $gapHorizontal);
|
||||
$cssRules[] = "@media (min-width: 992px) {
|
||||
.post-grid .post-card-col {
|
||||
flex: 0 0 {$desktopWidth};
|
||||
max-width: {$desktopWidth};
|
||||
}
|
||||
}";
|
||||
|
||||
return implode("\n", $cssRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el ancho de columna considerando el gap
|
||||
*
|
||||
* Con gap en flexbox, el ancho debe ser:
|
||||
* (100% - (n-1)*gap) / n
|
||||
*
|
||||
* @param string $cols Número de columnas
|
||||
* @param string $gap Valor del gap (ej: '1.5rem', '24px')
|
||||
* @return string Valor CSS con calc() si hay gap
|
||||
*/
|
||||
private function getColumnWidth(string $cols, string $gap): string
|
||||
{
|
||||
$colCount = (int)$cols;
|
||||
if ($colCount <= 0) {
|
||||
$colCount = 1;
|
||||
}
|
||||
|
||||
// Si es 1 columna, no hay gap entre columnas
|
||||
if ($colCount === 1) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
// Número de gaps = columnas - 1
|
||||
$gapCount = $colCount - 1;
|
||||
|
||||
// calc((100% - (n-1)*gap) / n)
|
||||
return sprintf('calc((100%% - %d * %s) / %d)', $gapCount, $gap, $colCount);
|
||||
}
|
||||
|
||||
private function buildHTML(array $data, string $visibilityClass): string
|
||||
{
|
||||
$content = $data['content'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
$media = $data['media'] ?? [];
|
||||
$layout = $data['layout'] ?? [];
|
||||
|
||||
$showThumbnail = $this->toBool($content['show_thumbnail'] ?? true);
|
||||
$showExcerpt = $this->toBool($content['show_excerpt'] ?? true);
|
||||
$showMeta = $this->toBool($content['show_meta'] ?? true);
|
||||
$showCategories = $this->toBool($content['show_categories'] ?? true);
|
||||
$excerptLength = (int)($content['excerpt_length'] ?? 20);
|
||||
$readMoreText = $content['read_more_text'] ?? 'Leer mas';
|
||||
$headingLevel = $typography['heading_level'] ?? 'h3';
|
||||
$fallbackImage = $media['fallback_image'] ?? '';
|
||||
$fallbackImageAlt = $media['fallback_image_alt'] ?? 'Imagen por defecto';
|
||||
$imagePosition = $layout['image_position'] ?? 'top';
|
||||
|
||||
$containerClass = 'post-grid';
|
||||
if (!empty($visibilityClass)) {
|
||||
$containerClass .= ' ' . $visibilityClass;
|
||||
}
|
||||
|
||||
$html = sprintf('<div class="%s">', esc_attr($containerClass));
|
||||
$html .= '<div class="row">';
|
||||
|
||||
while (have_posts()) {
|
||||
the_post();
|
||||
$html .= $this->buildCardHTML(
|
||||
$showThumbnail,
|
||||
$showExcerpt,
|
||||
$showMeta,
|
||||
$showCategories,
|
||||
$excerptLength,
|
||||
$readMoreText,
|
||||
$headingLevel,
|
||||
$fallbackImage,
|
||||
$fallbackImageAlt,
|
||||
$imagePosition
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
// Paginacion nativa de WordPress
|
||||
$html .= '<div class="pagination-wrapper">';
|
||||
$html .= $this->buildPaginationHTML();
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
wp_reset_postdata();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function buildCardHTML(
|
||||
bool $showThumbnail,
|
||||
bool $showExcerpt,
|
||||
bool $showMeta,
|
||||
bool $showCategories,
|
||||
int $excerptLength,
|
||||
string $readMoreText,
|
||||
string $headingLevel,
|
||||
string $fallbackImage,
|
||||
string $fallbackImageAlt,
|
||||
string $imagePosition
|
||||
): string {
|
||||
$permalink = get_permalink();
|
||||
$title = get_the_title();
|
||||
|
||||
$html = '<div class="post-card-col">';
|
||||
$html .= sprintf(
|
||||
'<a href="%s" class="text-decoration-none">',
|
||||
esc_url($permalink)
|
||||
);
|
||||
|
||||
$cardClass = 'card h-100';
|
||||
if ($imagePosition === 'left') {
|
||||
$cardClass .= ' flex-row';
|
||||
}
|
||||
|
||||
$html .= sprintf('<div class="%s">', esc_attr($cardClass));
|
||||
|
||||
// Imagen
|
||||
if ($showThumbnail && $imagePosition !== 'none') {
|
||||
$html .= $this->buildImageHTML($fallbackImage, $fallbackImageAlt, $imagePosition);
|
||||
}
|
||||
|
||||
$html .= '<div class="card-body">';
|
||||
|
||||
// Categorias
|
||||
if ($showCategories) {
|
||||
$html .= $this->buildCategoriesHTML();
|
||||
}
|
||||
|
||||
// Titulo
|
||||
$html .= sprintf(
|
||||
'<%s class="card-title">%s</%s>',
|
||||
esc_attr($headingLevel),
|
||||
esc_html($title),
|
||||
esc_attr($headingLevel)
|
||||
);
|
||||
|
||||
// Meta
|
||||
if ($showMeta) {
|
||||
$html .= $this->buildMetaHTML();
|
||||
}
|
||||
|
||||
// Excerpt
|
||||
if ($showExcerpt) {
|
||||
$html .= $this->buildExcerptHTML($excerptLength);
|
||||
}
|
||||
|
||||
$html .= '</div>'; // card-body
|
||||
$html .= '</div>'; // card
|
||||
$html .= '</a>';
|
||||
$html .= '</div>'; // col
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildImageHTML(string $fallbackImage, string $fallbackImageAlt, string $imagePosition): string
|
||||
{
|
||||
if (has_post_thumbnail()) {
|
||||
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
|
||||
return get_the_post_thumbnail(
|
||||
null,
|
||||
'medium_large',
|
||||
['class' => $imageClass, 'loading' => 'lazy']
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($fallbackImage)) {
|
||||
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
|
||||
return sprintf(
|
||||
'<img src="%s" alt="%s" class="%s" loading="lazy">',
|
||||
esc_url($fallbackImage),
|
||||
esc_attr($fallbackImageAlt),
|
||||
esc_attr($imageClass)
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildCategoriesHTML(): string
|
||||
{
|
||||
$categories = get_the_category();
|
||||
if (empty($categories)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="post-categories mb-2">';
|
||||
foreach (array_slice($categories, 0, 2) as $category) {
|
||||
$html .= sprintf(
|
||||
'<span class="post-category">%s</span>',
|
||||
esc_html($category->name)
|
||||
);
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMetaHTML(): string
|
||||
{
|
||||
$date = get_the_date();
|
||||
$author = get_the_author();
|
||||
|
||||
return sprintf(
|
||||
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
|
||||
esc_html($date),
|
||||
esc_html($author)
|
||||
);
|
||||
}
|
||||
|
||||
private function buildExcerptHTML(int $length): string
|
||||
{
|
||||
$excerpt = get_the_excerpt();
|
||||
|
||||
if (empty($excerpt)) {
|
||||
$excerpt = wp_trim_words(get_the_content(), $length, '...');
|
||||
} else {
|
||||
$excerpt = wp_trim_words($excerpt, $length, '...');
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<p class="card-text">%s</p>',
|
||||
esc_html($excerpt)
|
||||
);
|
||||
}
|
||||
|
||||
private function buildPaginationHTML(): string
|
||||
{
|
||||
global $wp_query;
|
||||
|
||||
$totalPages = $wp_query->max_num_pages;
|
||||
if ($totalPages <= 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$currentPage = max(1, get_query_var('paged', 1));
|
||||
$html = '<nav aria-label="Paginacion"><ul class="pagination justify-content-center">';
|
||||
|
||||
// Boton Inicio (siempre visible)
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">Inicio</a></li>',
|
||||
esc_url(get_pagenum_link(1))
|
||||
);
|
||||
|
||||
// Numeros de pagina - mostrar 5 paginas
|
||||
$visiblePages = 5;
|
||||
$start = max(1, $currentPage - 2);
|
||||
$end = min($totalPages, $start + $visiblePages - 1);
|
||||
|
||||
// Ajustar inicio si estamos cerca del final
|
||||
if ($end - $start < $visiblePages - 1) {
|
||||
$start = max(1, $end - $visiblePages + 1);
|
||||
}
|
||||
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i === $currentPage) {
|
||||
$html .= sprintf(
|
||||
'<li class="page-item active"><span class="page-link">%d</span></li>',
|
||||
$i
|
||||
);
|
||||
} else {
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">%d</a></li>',
|
||||
esc_url(get_pagenum_link($i)),
|
||||
$i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ver mas (siguiente pagina)
|
||||
if ($currentPage < $totalPages) {
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">Ver mas</a></li>',
|
||||
esc_url(get_pagenum_link($currentPage + 1))
|
||||
);
|
||||
}
|
||||
|
||||
// Boton Fin (siempre visible)
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">Fin</a></li>',
|
||||
esc_url(get_pagenum_link($totalPages))
|
||||
);
|
||||
|
||||
$html .= '</ul></nav>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,23 @@ final class TableOfContentsRenderer implements RendererInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
// Intentar primero con contenido filtrado (respeta shortcodes, etc.)
|
||||
$content = apply_filters('the_content', $post->post_content);
|
||||
|
||||
// Verificar si el contenido filtrado tiene headings
|
||||
$hasFilteredHeadings = preg_match('/<h[2-6][^>]*>/i', $content);
|
||||
|
||||
// FIX: Si el contenido filtrado no tiene headings pero el raw si,
|
||||
// usar el contenido raw. Esto ocurre cuando plugins como Thrive
|
||||
// transforman el contenido para usuarios no logueados.
|
||||
if (!$hasFilteredHeadings) {
|
||||
$hasRawHeadings = preg_match('/<h[2-6][^>]*>/i', $post->post_content);
|
||||
if ($hasRawHeadings) {
|
||||
// Usar wpautop para dar formato basico al contenido raw
|
||||
$content = wpautop($post->post_content);
|
||||
}
|
||||
}
|
||||
|
||||
$dom = new DOMDocument();
|
||||
libxml_use_internal_errors(true);
|
||||
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);
|
||||
|
||||
233
Schemas/archive-header.json
Normal file
233
Schemas/archive-header.json
Normal 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
271
Schemas/post-grid.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -22,15 +22,15 @@ final class EvaluatePageVisibilityUseCase
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evalúa si el componente debe mostrarse en la página actual
|
||||
* Evalua si el componente debe mostrarse en la pagina actual
|
||||
*/
|
||||
public function execute(string $componentName): bool
|
||||
{
|
||||
$config = $this->visibilityRepository->getVisibilityConfig($componentName);
|
||||
|
||||
if (empty($config)) {
|
||||
// Usar constante compartida (DRY)
|
||||
$config = VisibilityDefaults::DEFAULT_VISIBILITY;
|
||||
// Usar defaults especificos por componente si existen
|
||||
$config = VisibilityDefaults::getForComponent($componentName);
|
||||
}
|
||||
|
||||
$pageType = $this->pageTypeDetector->detect();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
|
||||
|
||||
/**
|
||||
* RenderPostGridRequest - DTO de entrada para renderizar post grid shortcode
|
||||
*
|
||||
* RESPONSABILIDAD: Encapsular todos los atributos del shortcode [roi_post_grid]
|
||||
*
|
||||
* USO:
|
||||
* ```php
|
||||
* $request = RenderPostGridRequest::fromArray([
|
||||
* 'category' => 'precios-unitarios',
|
||||
* 'posts_per_page' => 6,
|
||||
* 'columns' => 3
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
|
||||
*/
|
||||
final readonly class RenderPostGridRequest
|
||||
{
|
||||
public function __construct(
|
||||
public string $category = '',
|
||||
public string $excludeCategory = '',
|
||||
public string $tag = '',
|
||||
public string $author = '',
|
||||
public int $postsPerPage = 9,
|
||||
public int $columns = 3,
|
||||
public string $orderby = 'date',
|
||||
public string $order = 'DESC',
|
||||
public bool $showPagination = false,
|
||||
public int $offset = 0,
|
||||
public string $excludePosts = '',
|
||||
public bool $showThumbnail = true,
|
||||
public bool $showExcerpt = true,
|
||||
public bool $showMeta = true,
|
||||
public bool $showCategories = true,
|
||||
public int $excerptLength = 20,
|
||||
public string $class = '',
|
||||
public string $id = '',
|
||||
public int $paged = 1
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Factory method: Crear desde array de atributos del shortcode
|
||||
*
|
||||
* @param array<string, mixed> $atts Atributos sanitizados del shortcode
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $atts): self
|
||||
{
|
||||
return new self(
|
||||
category: (string) ($atts['category'] ?? ''),
|
||||
excludeCategory: (string) ($atts['exclude_category'] ?? ''),
|
||||
tag: (string) ($atts['tag'] ?? ''),
|
||||
author: (string) ($atts['author'] ?? ''),
|
||||
postsPerPage: self::clampInt((int) ($atts['posts_per_page'] ?? 9), 1, 50),
|
||||
columns: self::clampInt((int) ($atts['columns'] ?? 3), 1, 4),
|
||||
orderby: self::sanitizeOrderby((string) ($atts['orderby'] ?? 'date')),
|
||||
order: self::sanitizeOrder((string) ($atts['order'] ?? 'DESC')),
|
||||
showPagination: self::toBool($atts['show_pagination'] ?? false),
|
||||
offset: max(0, (int) ($atts['offset'] ?? 0)),
|
||||
excludePosts: (string) ($atts['exclude_posts'] ?? ''),
|
||||
showThumbnail: self::toBool($atts['show_thumbnail'] ?? true),
|
||||
showExcerpt: self::toBool($atts['show_excerpt'] ?? true),
|
||||
showMeta: self::toBool($atts['show_meta'] ?? true),
|
||||
showCategories: self::toBool($atts['show_categories'] ?? true),
|
||||
excerptLength: max(1, (int) ($atts['excerpt_length'] ?? 20)),
|
||||
class: (string) ($atts['class'] ?? ''),
|
||||
id: (string) ($atts['id'] ?? ''),
|
||||
paged: max(1, (int) ($atts['paged'] ?? 1))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir a array de parametros para QueryBuilder
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'category' => $this->category,
|
||||
'exclude_category' => $this->excludeCategory,
|
||||
'tag' => $this->tag,
|
||||
'author' => $this->author,
|
||||
'posts_per_page' => $this->postsPerPage,
|
||||
'orderby' => $this->orderby,
|
||||
'order' => $this->order,
|
||||
'offset' => $this->offset,
|
||||
'exclude_posts' => $this->excludePosts,
|
||||
'paged' => $this->paged,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir a array de opciones para Renderer
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toRenderOptions(): array
|
||||
{
|
||||
return [
|
||||
'columns' => $this->columns,
|
||||
'show_thumbnail' => $this->showThumbnail,
|
||||
'show_excerpt' => $this->showExcerpt,
|
||||
'show_meta' => $this->showMeta,
|
||||
'show_categories' => $this->showCategories,
|
||||
'excerpt_length' => $this->excerptLength,
|
||||
'class' => $this->class,
|
||||
'id' => $this->id,
|
||||
'show_pagination' => $this->showPagination,
|
||||
'posts_per_page' => $this->postsPerPage,
|
||||
];
|
||||
}
|
||||
|
||||
private static function clampInt(int $value, int $min, int $max): int
|
||||
{
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private static function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return $value === 'true' || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private static function sanitizeOrderby(string $value): string
|
||||
{
|
||||
$allowed = ['date', 'title', 'modified', 'rand', 'comment_count'];
|
||||
return in_array($value, $allowed, true) ? $value : 'date';
|
||||
}
|
||||
|
||||
private static function sanitizeOrder(string $value): string
|
||||
{
|
||||
$upper = strtoupper($value);
|
||||
return in_array($upper, ['ASC', 'DESC'], true) ? $upper : 'DESC';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
|
||||
|
||||
/**
|
||||
* Caso de uso: Renderizar grid de posts para shortcode
|
||||
*
|
||||
* RESPONSABILIDAD: Orquestar la obtencion de settings, construccion de query
|
||||
* y renderizado del grid. No contiene logica de negocio, solo coordinacion.
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
|
||||
*/
|
||||
final class RenderPostGridUseCase
|
||||
{
|
||||
private const COMPONENT_NAME = 'post-grid';
|
||||
|
||||
public function __construct(
|
||||
private readonly PostGridQueryBuilderInterface $queryBuilder,
|
||||
private readonly PostGridShortcodeRendererInterface $renderer,
|
||||
private readonly ComponentSettingsRepositoryInterface $settingsRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ejecuta el caso de uso: obtiene settings, construye query y renderiza
|
||||
*
|
||||
* @param RenderPostGridRequest $request DTO con atributos del shortcode
|
||||
* @return string HTML del grid renderizado
|
||||
*/
|
||||
public function execute(RenderPostGridRequest $request): string
|
||||
{
|
||||
// 1. Obtener settings del componente post-grid desde BD
|
||||
$settings = $this->getSettings();
|
||||
|
||||
// 2. Construir query con los parametros del shortcode
|
||||
$query = $this->queryBuilder->build($request->toQueryParams());
|
||||
|
||||
// 3. Renderizar grid con query, settings y opciones
|
||||
$html = $this->renderer->render(
|
||||
$query,
|
||||
$settings,
|
||||
$request->toRenderOptions()
|
||||
);
|
||||
|
||||
// 4. Limpiar query (importante para evitar conflictos)
|
||||
wp_reset_postdata();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene settings del componente post-grid
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getSettings(): array
|
||||
{
|
||||
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
|
||||
|
||||
if (empty($settings)) {
|
||||
return VisibilityDefaults::getForComponent(self::COMPONENT_NAME);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,13 @@ namespace ROITheme\Shared\Domain\Constants;
|
||||
final class VisibilityDefaults
|
||||
{
|
||||
/**
|
||||
* Configuración de visibilidad por defecto para nuevos componentes
|
||||
* Configuracion de visibilidad por defecto para componentes generales
|
||||
*
|
||||
* - Home: SÍ mostrar (página principal)
|
||||
* - Posts: SÍ mostrar (artículos del blog)
|
||||
* - Pages: SÍ mostrar (páginas estáticas)
|
||||
* - Archives: NO mostrar (listados de categorías/tags)
|
||||
* - Search: NO mostrar (resultados de búsqueda)
|
||||
* - Home: SI mostrar (pagina principal)
|
||||
* - Posts: SI mostrar (articulos del blog)
|
||||
* - Pages: SI mostrar (paginas estaticas)
|
||||
* - Archives: NO mostrar (listados de categorias/tags)
|
||||
* - Search: NO mostrar (resultados de busqueda)
|
||||
*/
|
||||
public const DEFAULT_VISIBILITY = [
|
||||
'show_on_home' => true,
|
||||
@@ -33,7 +33,39 @@ final class VisibilityDefaults
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista de campos de visibilidad válidos
|
||||
* Defaults especificos por componente (sobrescriben DEFAULT_VISIBILITY)
|
||||
*
|
||||
* Componentes de listados:
|
||||
* - archive-header: Solo en archives (home para blog title)
|
||||
* - post-grid: En home, archives y search
|
||||
* - cta-box-sidebar: Tambien en archives
|
||||
*/
|
||||
public const COMPONENT_VISIBILITY = [
|
||||
'archive-header' => [
|
||||
'show_on_home' => true, // Para mostrar blog_title
|
||||
'show_on_posts' => false,
|
||||
'show_on_pages' => false,
|
||||
'show_on_archives' => true, // Proposito principal
|
||||
'show_on_search' => true, // Mostrar "Resultados: X"
|
||||
],
|
||||
'post-grid' => [
|
||||
'show_on_home' => true, // Blog principal
|
||||
'show_on_posts' => false,
|
||||
'show_on_pages' => false,
|
||||
'show_on_archives' => true, // Listados de categoria/tag
|
||||
'show_on_search' => true, // Resultados de busqueda
|
||||
],
|
||||
'cta-box-sidebar' => [
|
||||
'show_on_home' => true,
|
||||
'show_on_posts' => true,
|
||||
'show_on_pages' => true,
|
||||
'show_on_archives' => true, // Visible en archives
|
||||
'show_on_search' => false,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista de campos de visibilidad validos
|
||||
*/
|
||||
public const VISIBILITY_FIELDS = [
|
||||
'show_on_home',
|
||||
@@ -42,4 +74,15 @@ final class VisibilityDefaults
|
||||
'show_on_archives',
|
||||
'show_on_search',
|
||||
];
|
||||
|
||||
/**
|
||||
* Obtiene los defaults para un componente especifico
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return array<string, bool> Configuracion de visibilidad
|
||||
*/
|
||||
public static function getForComponent(string $componentName): array
|
||||
{
|
||||
return self::COMPONENT_VISIBILITY[$componentName] ?? self::DEFAULT_VISIBILITY;
|
||||
}
|
||||
}
|
||||
|
||||
51
Shared/Domain/Contracts/PostGridQueryBuilderInterface.php
Normal file
51
Shared/Domain/Contracts/PostGridQueryBuilderInterface.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Interface PostGridQueryBuilderInterface
|
||||
*
|
||||
* Contrato para construccion de queries de posts para el shortcode post-grid.
|
||||
* Define el comportamiento esperado para construir WP_Query sin depender
|
||||
* de implementaciones especificas.
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Construir WP_Query a partir de parametros de filtro
|
||||
* - Aplicar filtros por categoria, tag, autor
|
||||
* - Configurar paginacion y ordenamiento
|
||||
*
|
||||
* NO responsable de:
|
||||
* - Generar HTML
|
||||
* - Generar CSS
|
||||
* - Obtener settings de BD
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PostGridQueryBuilderInterface
|
||||
{
|
||||
/**
|
||||
* Construye un WP_Query configurado con los parametros proporcionados.
|
||||
*
|
||||
* Ejemplo:
|
||||
* ```php
|
||||
* $params = [
|
||||
* 'category' => 'precios-unitarios',
|
||||
* 'tag' => 'concreto',
|
||||
* 'posts_per_page' => 6,
|
||||
* 'orderby' => 'date',
|
||||
* 'order' => 'DESC'
|
||||
* ];
|
||||
*
|
||||
* $query = $builder->build($params);
|
||||
* ```
|
||||
*
|
||||
* @param array<string, mixed> $params Parametros de filtro y configuracion
|
||||
* Keys soportadas: category, exclude_category, tag,
|
||||
* author, posts_per_page, orderby, order, offset,
|
||||
* exclude_posts, paged
|
||||
*
|
||||
* @return \WP_Query Query configurado listo para iterar
|
||||
*/
|
||||
public function build(array $params): \WP_Query;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Interface PostGridShortcodeRendererInterface
|
||||
*
|
||||
* Contrato para renderizado HTML del shortcode post-grid.
|
||||
* Define el comportamiento esperado para generar el HTML del grid
|
||||
* sin depender de implementaciones especificas.
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Generar HTML del grid de posts
|
||||
* - Generar CSS inline usando CSSGeneratorInterface
|
||||
* - Aplicar clases responsive de Bootstrap
|
||||
*
|
||||
* NO responsable de:
|
||||
* - Construir queries
|
||||
* - Obtener settings de BD
|
||||
* - Sanitizar atributos del shortcode
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PostGridShortcodeRendererInterface
|
||||
{
|
||||
/**
|
||||
* Renderiza el grid de posts como HTML.
|
||||
*
|
||||
* Ejemplo:
|
||||
* ```php
|
||||
* $html = $renderer->render($query, $settings, [
|
||||
* 'columns' => 3,
|
||||
* 'show_thumbnail' => true,
|
||||
* 'show_excerpt' => true,
|
||||
* 'id' => 'grid-cursos'
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @param \WP_Query $query Query con los posts a mostrar
|
||||
* @param array<string, mixed> $settings Settings del componente post-grid desde BD
|
||||
* @param array<string, mixed> $options Opciones de visualizacion del shortcode
|
||||
* Keys: columns, show_thumbnail, show_excerpt,
|
||||
* show_meta, show_categories, excerpt_length,
|
||||
* class, id, show_pagination
|
||||
*
|
||||
* @return string HTML completo del grid incluyendo CSS inline
|
||||
*/
|
||||
public function render(\WP_Query $query, array $settings, array $options): string;
|
||||
}
|
||||
@@ -42,6 +42,12 @@ use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
|
||||
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentVisibilityRepository;
|
||||
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
|
||||
use ROITheme\Shared\Infrastructure\Wordpress\BodyClassHooksRegistrar;
|
||||
// Post Grid Shortcode (Plan post-grid-shortcode)
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Infrastructure\Query\PostGridQueryBuilder;
|
||||
use ROITheme\Shared\Infrastructure\Ui\PostGridShortcodeRenderer;
|
||||
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridUseCase;
|
||||
|
||||
/**
|
||||
* DIContainer - Contenedor de Inyección de Dependencias
|
||||
@@ -493,4 +499,47 @@ final class DIContainer
|
||||
}
|
||||
return $this->instances['bodyClassHooksRegistrar'];
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Post Grid Shortcode System
|
||||
// ===============================
|
||||
|
||||
/**
|
||||
* Obtiene el query builder para post grid shortcode
|
||||
*/
|
||||
public function getPostGridQueryBuilder(): PostGridQueryBuilderInterface
|
||||
{
|
||||
if (!isset($this->instances['postGridQueryBuilder'])) {
|
||||
$this->instances['postGridQueryBuilder'] = new PostGridQueryBuilder();
|
||||
}
|
||||
return $this->instances['postGridQueryBuilder'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el renderer para post grid shortcode
|
||||
*/
|
||||
public function getPostGridShortcodeRenderer(): PostGridShortcodeRendererInterface
|
||||
{
|
||||
if (!isset($this->instances['postGridShortcodeRenderer'])) {
|
||||
$this->instances['postGridShortcodeRenderer'] = new PostGridShortcodeRenderer(
|
||||
$this->getCSSGeneratorService()
|
||||
);
|
||||
}
|
||||
return $this->instances['postGridShortcodeRenderer'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el caso de uso para renderizar post grid shortcode
|
||||
*/
|
||||
public function getRenderPostGridUseCase(): RenderPostGridUseCase
|
||||
{
|
||||
if (!isset($this->instances['renderPostGridUseCase'])) {
|
||||
$this->instances['renderPostGridUseCase'] = new RenderPostGridUseCase(
|
||||
$this->getPostGridQueryBuilder(),
|
||||
$this->getPostGridShortcodeRenderer(),
|
||||
$this->getComponentSettingsRepository()
|
||||
);
|
||||
}
|
||||
return $this->instances['renderPostGridUseCase'];
|
||||
}
|
||||
}
|
||||
|
||||
82
Shared/Infrastructure/Hooks/CacheFirstHooksRegistrar.php
Normal file
82
Shared/Infrastructure/Hooks/CacheFirstHooksRegistrar.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Hooks;
|
||||
|
||||
/**
|
||||
* Registra hooks para arquitectura cache-first.
|
||||
*
|
||||
* Permite que plugins externos evalúen condiciones ANTES de servir páginas,
|
||||
* sin bloquear el cache de WordPress.
|
||||
*
|
||||
* @see openspec/specs/cache-first-architecture/spec.md
|
||||
* @package ROITheme\Shared\Infrastructure\Hooks
|
||||
*/
|
||||
final class CacheFirstHooksRegistrar
|
||||
{
|
||||
/**
|
||||
* Registra los hooks de cache-first.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
add_action('template_redirect', [$this, 'fireBeforePageServe'], 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara hook para que plugins externos evalúen acceso.
|
||||
*
|
||||
* Solo se dispara para:
|
||||
* - Páginas singulares (posts, pages, CPTs)
|
||||
* - Visitantes NO logueados (cache no aplica a usuarios logueados)
|
||||
*
|
||||
* Los plugins pueden llamar wp_safe_redirect() + exit para bloquear.
|
||||
* Si no hacen nada, la página se sirve normalmente (con cache si disponible).
|
||||
*/
|
||||
public function fireBeforePageServe(): void
|
||||
{
|
||||
// No para usuarios logueados (cache no aplica, no tiene sentido evaluar)
|
||||
if (is_user_logged_in()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Solo páginas singulares
|
||||
if (!is_singular()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No en admin/ajax/cron/REST
|
||||
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defined('REST_REQUEST') && REST_REQUEST) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ($post_id > 0) {
|
||||
/**
|
||||
* Hook: roi_theme_before_page_serve
|
||||
*
|
||||
* Permite que plugins externos evalúen condiciones antes de servir página.
|
||||
*
|
||||
* Uso típico:
|
||||
* - Rate limiters (límite de vistas por IP)
|
||||
* - Membership plugins (verificar acceso)
|
||||
* - Geolocation restrictions
|
||||
*
|
||||
* Para bloquear acceso:
|
||||
* wp_safe_redirect('/pagina-destino/', 302);
|
||||
* exit;
|
||||
*
|
||||
* Para permitir acceso:
|
||||
* return; // La página se servirá (con cache si disponible)
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param int $post_id ID del post/page que se va a servir
|
||||
*/
|
||||
do_action('roi_theme_before_page_serve', $post_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,15 +61,35 @@ final class WordPressComponentVisibilityRepository implements WrapperVisibilityC
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Delega a PageVisibilityHelper que ya implementa:
|
||||
* Evalúa múltiples criterios de exclusión:
|
||||
* - hide_for_logged_in: Ocultar para usuarios logueados
|
||||
* - Visibilidad por tipo de página (home, posts, pages, archives, search)
|
||||
* - Exclusiones por categoría, post ID, URL pattern
|
||||
*/
|
||||
public function isNotExcluded(string $componentName): bool
|
||||
{
|
||||
// Verificar hide_for_logged_in
|
||||
if ($this->shouldHideForLoggedIn($componentName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PageVisibilityHelper::shouldShow($componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si debe ocultarse para usuarios logueados
|
||||
*/
|
||||
private function shouldHideForLoggedIn(string $componentName): bool
|
||||
{
|
||||
$value = $this->getVisibilityAttribute($componentName, 'hide_for_logged_in');
|
||||
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->toBool($value) && is_user_logged_in();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un atributo del grupo visibility desde la BD
|
||||
*
|
||||
|
||||
109
Shared/Infrastructure/Query/PostGridQueryBuilder.php
Normal file
109
Shared/Infrastructure/Query/PostGridQueryBuilder.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Query;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
|
||||
|
||||
/**
|
||||
* Implementacion de PostGridQueryBuilderInterface
|
||||
*
|
||||
* RESPONSABILIDAD: Construir WP_Query a partir de parametros de filtro.
|
||||
* No genera HTML ni obtiene settings.
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Query
|
||||
*/
|
||||
final class PostGridQueryBuilder implements PostGridQueryBuilderInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function build(array $params): \WP_Query
|
||||
{
|
||||
$args = [
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => (int) ($params['posts_per_page'] ?? 9),
|
||||
'orderby' => $params['orderby'] ?? 'date',
|
||||
'order' => $params['order'] ?? 'DESC',
|
||||
'paged' => (int) ($params['paged'] ?? 1),
|
||||
];
|
||||
|
||||
// Offset
|
||||
if (!empty($params['offset'])) {
|
||||
$args['offset'] = (int) $params['offset'];
|
||||
}
|
||||
|
||||
// Filtro por categoria(s)
|
||||
if (!empty($params['category'])) {
|
||||
$args['category_name'] = $this->sanitizeSlugs($params['category']);
|
||||
}
|
||||
|
||||
// Excluir categoria(s)
|
||||
if (!empty($params['exclude_category'])) {
|
||||
$excludeIds = $this->getCategoryIds($params['exclude_category']);
|
||||
if (!empty($excludeIds)) {
|
||||
$args['category__not_in'] = $excludeIds;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtro por tag(s)
|
||||
if (!empty($params['tag'])) {
|
||||
$args['tag'] = $this->sanitizeSlugs($params['tag']);
|
||||
}
|
||||
|
||||
// Filtro por autor
|
||||
if (!empty($params['author'])) {
|
||||
$author = $params['author'];
|
||||
if (is_numeric($author)) {
|
||||
$args['author'] = (int) $author;
|
||||
} else {
|
||||
$user = get_user_by('login', $author);
|
||||
if ($user) {
|
||||
$args['author'] = $user->ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Excluir posts por ID
|
||||
if (!empty($params['exclude_posts'])) {
|
||||
$excludeIds = array_map('intval', explode(',', $params['exclude_posts']));
|
||||
$excludeIds = array_filter($excludeIds, fn($id) => $id > 0);
|
||||
if (!empty($excludeIds)) {
|
||||
$args['post__not_in'] = $excludeIds;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Query($args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiza slugs separados por coma
|
||||
*/
|
||||
private function sanitizeSlugs(string $slugs): string
|
||||
{
|
||||
$parts = explode(',', $slugs);
|
||||
$sanitized = array_map('sanitize_title', $parts);
|
||||
return implode(',', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene IDs de categorias desde slugs
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function getCategoryIds(string $slugs): array
|
||||
{
|
||||
$parts = explode(',', $slugs);
|
||||
$ids = [];
|
||||
|
||||
foreach ($parts as $slug) {
|
||||
$term = get_term_by('slug', trim($slug), 'category');
|
||||
if ($term instanceof \WP_Term) {
|
||||
$ids[] = $term->term_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Busca casos variados de problemas de listas para validación exhaustiva
|
||||
*/
|
||||
|
||||
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
|
||||
$conn->set_charset("utf8mb4");
|
||||
|
||||
function detectIssues($html) {
|
||||
$issues = [];
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument("1.0", "UTF-8");
|
||||
$wrapped = '<div id="wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ["li", "script", "template"];
|
||||
foreach (["ul", "ol"] as $tag) {
|
||||
foreach ($doc->getElementsByTagName($tag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$childTag = strtolower($child->nodeName);
|
||||
if (!in_array($childTag, $validChildren)) {
|
||||
$issues[] = ["parent" => $tag, "child" => $childTag];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $issues;
|
||||
}
|
||||
|
||||
echo "BUSCANDO CASOS VARIADOS...\n\n";
|
||||
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE html IS NOT NULL AND html != '' ORDER BY id";
|
||||
$result = $conn->query($query);
|
||||
|
||||
if (!$result) {
|
||||
die("Error en query: " . $conn->error);
|
||||
}
|
||||
|
||||
$cases = [
|
||||
"many_issues" => [],
|
||||
"ol_issues" => [],
|
||||
"mixed_issues" => [],
|
||||
"few_issues" => []
|
||||
];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row["html"]);
|
||||
if (empty($issues)) continue;
|
||||
|
||||
$count = count($issues);
|
||||
$hasOl = false;
|
||||
$hasUl = false;
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
if ($issue["parent"] === "ol") $hasOl = true;
|
||||
if ($issue["parent"] === "ul") $hasUl = true;
|
||||
}
|
||||
|
||||
if ($count > 10 && count($cases["many_issues"]) < 3) {
|
||||
$cases["many_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
if ($hasOl && !$hasUl && count($cases["ol_issues"]) < 3) {
|
||||
$cases["ol_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
if ($hasOl && $hasUl && count($cases["mixed_issues"]) < 3) {
|
||||
$cases["mixed_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
if ($count <= 2 && count($cases["few_issues"]) < 3) {
|
||||
$cases["few_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($cases as $type => $posts) {
|
||||
echo "=== " . strtoupper($type) . " ===\n";
|
||||
if (empty($posts)) {
|
||||
echo " (ninguno encontrado)\n\n";
|
||||
continue;
|
||||
}
|
||||
foreach ($posts as $post) {
|
||||
echo "ID: {$post["id"]} - {$post["count"]} problemas\n";
|
||||
echo "URL: {$post["url"]}\n";
|
||||
echo "Tipos: ";
|
||||
$types = [];
|
||||
foreach ($post["issues"] as $i) {
|
||||
$types[] = "<{$i["parent"]}> contiene <{$i["child"]}>";
|
||||
}
|
||||
echo implode(", ", array_unique($types)) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
@@ -1,411 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Corrector de Listas HTML Mal Formadas usando DOMDocument
|
||||
*
|
||||
* PROPÓSITO: Detectar y corregir listas con estructura inválida
|
||||
* - <ul>/<ol> conteniendo elementos no-<li> como hijos directos
|
||||
* - Listas anidadas que son hermanas en lugar de hijas de <li>
|
||||
*
|
||||
* USO:
|
||||
* php fix-malformed-lists-dom.php --mode=scan # Solo escanear
|
||||
* php fix-malformed-lists-dom.php --mode=test # Probar corrección (1 post)
|
||||
* php fix-malformed-lists-dom.php --mode=fix # Aplicar correcciones
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since Phase 4.4 Accessibility
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(600);
|
||||
|
||||
// Configuración
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_seo',
|
||||
'username' => 'preciosunitarios_seo',
|
||||
'password' => 'ACl%EEFd=V-Yvb??',
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
// Parsear argumentos
|
||||
$mode = 'scan';
|
||||
foreach ($argv as $arg) {
|
||||
if (strpos($arg, '--mode=') === 0) {
|
||||
$mode = substr($arg, 7);
|
||||
}
|
||||
}
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " CORRECTOR DE LISTAS - DOMDocument\n";
|
||||
echo " Modo: $mode\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
/**
|
||||
* Conectar a la base de datos
|
||||
*/
|
||||
function connectDatabase(array $config): ?mysqli {
|
||||
$conn = new mysqli(
|
||||
$config['host'],
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['database']
|
||||
);
|
||||
if ($conn->connect_error) {
|
||||
echo "Error de conexión: " . $conn->connect_error . "\n";
|
||||
return null;
|
||||
}
|
||||
$conn->set_charset($config['charset']);
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregir listas mal formadas usando DOMDocument
|
||||
*/
|
||||
function fixMalformedLists(string $html): array {
|
||||
$result = [
|
||||
'fixed' => false,
|
||||
'html' => $html,
|
||||
'changes' => 0,
|
||||
'details' => []
|
||||
];
|
||||
|
||||
// Suprimir errores de HTML mal formado
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
// Envolver en contenedor para preservar estructura
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
// Procesar todas las listas (ul y ol)
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) {
|
||||
$lists[] = $ul;
|
||||
}
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) {
|
||||
$lists[] = $ol;
|
||||
}
|
||||
|
||||
$changes = 0;
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$changes += fixListChildren($list, $result['details']);
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
// Extraer HTML corregido
|
||||
$wrapper = $doc->getElementById('temp-wrapper');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregir hijos de una lista (solo debe contener li, script, template)
|
||||
*/
|
||||
function fixListChildren(DOMElement $list, array &$details): int {
|
||||
$changes = 0;
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
$nodesToProcess = [];
|
||||
|
||||
// Recopilar nodos que necesitan corrección
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar cada nodo inválido
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
|
||||
// Si es una lista anidada (ul/ol), envolverla en <li>
|
||||
if ($tagName === 'ul' || $tagName === 'ol') {
|
||||
$changes += wrapInLi($list, $node, $details);
|
||||
}
|
||||
// Otros elementos inválidos también se envuelven en <li>
|
||||
else {
|
||||
$changes += wrapInLi($list, $node, $details);
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envolver un nodo en <li> o moverlo al <li> anterior
|
||||
*/
|
||||
function wrapInLi(DOMElement $list, DOMNode $node, array &$details): int {
|
||||
$doc = $list->ownerDocument;
|
||||
$tagName = strtolower($node->nodeName);
|
||||
|
||||
// Buscar el <li> hermano anterior
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
// Mover el nodo al final del <li> anterior
|
||||
$prevLi->appendChild($node);
|
||||
$details[] = "Movido <$tagName> dentro del <li> anterior";
|
||||
return 1;
|
||||
} else {
|
||||
// No hay <li> anterior, crear uno nuevo
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$details[] = "Envuelto <$tagName> en nuevo <li>";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detectar problemas en HTML sin corregir
|
||||
*/
|
||||
function detectIssues(string $html): array {
|
||||
$issues = [];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
// Revisar ul
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) {
|
||||
foreach ($ul->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = [
|
||||
'list_type' => 'ul',
|
||||
'invalid_child' => $tagName,
|
||||
'context' => getNodeContext($child)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Revisar ol
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) {
|
||||
foreach ($ol->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = [
|
||||
'list_type' => 'ol',
|
||||
'invalid_child' => $tagName,
|
||||
'context' => getNodeContext($child)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener contexto de un nodo para debug
|
||||
*/
|
||||
function getNodeContext(DOMNode $node): string {
|
||||
$doc = $node->ownerDocument;
|
||||
$html = $doc->saveHTML($node);
|
||||
return substr($html, 0, 100) . (strlen($html) > 100 ? '...' : '');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EJECUCIÓN PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
$conn = connectDatabase($db_config);
|
||||
if (!$conn) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Contar registros
|
||||
$result = $conn->query("SELECT COUNT(*) as total FROM datos_seo_pagina WHERE html IS NOT NULL AND html != ''");
|
||||
$total = $result->fetch_assoc()['total'];
|
||||
echo "Total de registros: $total\n\n";
|
||||
|
||||
if ($mode === 'scan') {
|
||||
// MODO SCAN: Solo detectar problemas
|
||||
echo "MODO: ESCANEO (solo detección)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 100;
|
||||
$offset = 0;
|
||||
$affected = 0;
|
||||
$total_issues = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
if (!empty($issues)) {
|
||||
$affected++;
|
||||
$total_issues += count($issues);
|
||||
|
||||
if ($affected <= 20) {
|
||||
echo "[ID: {$row['id']}] " . count($issues) . " problema(s)\n";
|
||||
echo "URL: {$row['page']}\n";
|
||||
foreach (array_slice($issues, 0, 2) as $issue) {
|
||||
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 1000 == 0) {
|
||||
echo "Procesados: $offset/$total...\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts afectados: $affected\n";
|
||||
echo " Total incidencias: $total_issues\n";
|
||||
|
||||
} elseif ($mode === 'test') {
|
||||
// MODO TEST: Probar corrección en 1 post
|
||||
echo "MODO: PRUEBA (sin guardar)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
// Buscar primer post con problemas
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id LIMIT 100";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
if (!empty($issues)) {
|
||||
echo "POST ID: {$row['id']}\n";
|
||||
echo "URL: {$row['page']}\n";
|
||||
echo "Problemas detectados: " . count($issues) . "\n\n";
|
||||
|
||||
echo "ANTES (problemas):\n";
|
||||
foreach (array_slice($issues, 0, 3) as $issue) {
|
||||
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
|
||||
echo " Contexto: " . htmlspecialchars(substr($issue['context'], 0, 80)) . "\n";
|
||||
}
|
||||
|
||||
// Aplicar corrección
|
||||
$fixResult = fixMalformedLists($row['html']);
|
||||
|
||||
echo "\nDESPUÉS (corrección):\n";
|
||||
echo " Cambios realizados: {$fixResult['changes']}\n";
|
||||
foreach ($fixResult['details'] as $detail) {
|
||||
echo " - $detail\n";
|
||||
}
|
||||
|
||||
// Verificar que no quedan problemas
|
||||
$issuesAfter = detectIssues($fixResult['html']);
|
||||
echo "\nVERIFICACIÓN:\n";
|
||||
echo " Problemas antes: " . count($issues) . "\n";
|
||||
echo " Problemas después: " . count($issuesAfter) . "\n";
|
||||
|
||||
if (count($issuesAfter) < count($issues)) {
|
||||
echo " ✓ Reducción de problemas\n";
|
||||
}
|
||||
|
||||
// Mostrar fragmento del HTML corregido
|
||||
if ($fixResult['fixed']) {
|
||||
echo "\nMUESTRA HTML CORREGIDO (primeros 500 chars):\n";
|
||||
echo "─────────────────────────────────\n";
|
||||
echo htmlspecialchars(substr($fixResult['html'], 0, 500)) . "...\n";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} elseif ($mode === 'fix') {
|
||||
// MODO FIX: Aplicar correcciones
|
||||
echo "MODO: CORRECCIÓN (GUARDANDO CAMBIOS)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 50;
|
||||
$offset = 0;
|
||||
$fixed_count = 0;
|
||||
$error_count = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
|
||||
if (!empty($issues)) {
|
||||
$fixResult = fixMalformedLists($row['html']);
|
||||
|
||||
if ($fixResult['fixed']) {
|
||||
// Guardar HTML corregido
|
||||
$stmt = $conn->prepare("UPDATE datos_seo_pagina SET html = ? WHERE id = ?");
|
||||
$stmt->bind_param("si", $fixResult['html'], $row['id']);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$fixed_count++;
|
||||
echo "[ID: {$row['id']}] ✓ Corregido ({$fixResult['changes']} cambios)\n";
|
||||
} else {
|
||||
$error_count++;
|
||||
echo "[ID: {$row['id']}] ✗ Error al guardar\n";
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 500 == 0) {
|
||||
echo "Procesados: $offset/$total (corregidos: $fixed_count)\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts corregidos: $fixed_count\n";
|
||||
echo " Errores: $error_count\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "\n✓ Proceso completado.\n";
|
||||
@@ -1,322 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Corrector de Listas HTML Mal Formadas - WordPress Posts
|
||||
*
|
||||
* BASE DE DATOS: preciosunitarios_wp
|
||||
* TABLA: wp_posts
|
||||
* CAMPO: post_content
|
||||
*
|
||||
* USO:
|
||||
* php fix-malformed-lists-wp-posts.php --mode=scan
|
||||
* php fix-malformed-lists-wp-posts.php --mode=test
|
||||
* php fix-malformed-lists-wp-posts.php --mode=fix
|
||||
*
|
||||
* @package ROI_Theme
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(600);
|
||||
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_wp',
|
||||
'username' => 'preciosunitarios_wp',
|
||||
'password' => 'Kq#Gk%yEt+PWpVe&HZ',
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
$mode = 'scan';
|
||||
foreach ($argv as $arg) {
|
||||
if (strpos($arg, '--mode=') === 0) {
|
||||
$mode = substr($arg, 7);
|
||||
}
|
||||
}
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " CORRECTOR DE LISTAS - WordPress Posts\n";
|
||||
echo " Base de datos: {$db_config['database']}\n";
|
||||
echo " Tabla: wp_posts (post_content)\n";
|
||||
echo " Modo: $mode\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
function connectDatabase(array $config): ?mysqli {
|
||||
$conn = new mysqli($config['host'], $config['username'], $config['password'], $config['database']);
|
||||
if ($conn->connect_error) {
|
||||
echo "Error de conexión: " . $conn->connect_error . "\n";
|
||||
return null;
|
||||
}
|
||||
$conn->set_charset($config['charset']);
|
||||
return $conn;
|
||||
}
|
||||
|
||||
function detectIssues(string $html): array {
|
||||
$issues = [];
|
||||
if (empty(trim($html))) return $issues;
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach (['ul', 'ol'] as $listTag) {
|
||||
foreach ($doc->getElementsByTagName($listTag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = [
|
||||
'list_type' => $listTag,
|
||||
'invalid_child' => $tagName
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
function fixMalformedLists(string $html): array {
|
||||
$result = ['fixed' => false, 'html' => $html, 'changes' => 0, 'details' => []];
|
||||
|
||||
if (empty(trim($html))) return $result;
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
|
||||
|
||||
$changes = 0;
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$nodesToProcess = [];
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
$prevLi->appendChild($node);
|
||||
$result['details'][] = "Movido <$tagName> dentro del <li> anterior";
|
||||
$changes++;
|
||||
} else {
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$result['details'][] = "Envuelto <$tagName> en nuevo <li>";
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$wrapper = $doc->getElementById('temp-wrapper');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// EJECUCIÓN PRINCIPAL
|
||||
$conn = connectDatabase($db_config);
|
||||
if (!$conn) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Solo posts publicados con contenido
|
||||
$countQuery = "SELECT COUNT(*) as total FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''";
|
||||
$result = $conn->query($countQuery);
|
||||
$total = $result->fetch_assoc()['total'];
|
||||
echo "Total de posts/páginas publicados: $total\n\n";
|
||||
|
||||
if ($mode === 'scan') {
|
||||
echo "MODO: ESCANEO (solo detección)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 100;
|
||||
$offset = 0;
|
||||
$affected = 0;
|
||||
$total_issues = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT ID, post_title, post_content, guid FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''
|
||||
ORDER BY ID LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['post_content']);
|
||||
if (!empty($issues)) {
|
||||
$affected++;
|
||||
$total_issues += count($issues);
|
||||
|
||||
if ($affected <= 20) {
|
||||
echo "[ID: {$row['ID']}] " . count($issues) . " problema(s)\n";
|
||||
echo "Título: " . substr($row['post_title'], 0, 60) . "\n";
|
||||
foreach (array_slice($issues, 0, 2) as $issue) {
|
||||
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 1000 == 0) {
|
||||
echo "Procesados: $offset/$total...\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts afectados: $affected\n";
|
||||
echo " Total incidencias: $total_issues\n";
|
||||
|
||||
} elseif ($mode === 'test') {
|
||||
echo "MODO: PRUEBA (sin guardar)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$query = "SELECT ID, post_title, post_content FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''
|
||||
ORDER BY ID LIMIT 200";
|
||||
$result = $conn->query($query);
|
||||
|
||||
$tested = 0;
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['post_content']);
|
||||
if (!empty($issues) && $tested < 5) {
|
||||
$tested++;
|
||||
echo "POST ID: {$row['ID']}\n";
|
||||
echo "Título: {$row['post_title']}\n";
|
||||
echo "Problemas detectados: " . count($issues) . "\n\n";
|
||||
|
||||
$fixResult = fixMalformedLists($row['post_content']);
|
||||
$issuesAfter = detectIssues($fixResult['html']);
|
||||
|
||||
echo "ANTES: " . count($issues) . " problemas\n";
|
||||
echo "DESPUÉS: " . count($issuesAfter) . " problemas\n";
|
||||
echo "Cambios: {$fixResult['changes']}\n";
|
||||
|
||||
// Verificar integridad
|
||||
$before_ul = substr_count($row['post_content'], '<ul');
|
||||
$after_ul = substr_count($fixResult['html'], '<ul');
|
||||
$before_li = substr_count($row['post_content'], '<li');
|
||||
$after_li = substr_count($fixResult['html'], '<li');
|
||||
|
||||
echo "Tags <ul>: $before_ul → $after_ul " . ($before_ul === $after_ul ? "✓" : "⚠️") . "\n";
|
||||
echo "Tags <li>: $before_li → $after_li " . ($before_li === $after_li ? "✓" : "⚠️") . "\n";
|
||||
|
||||
if (count($issuesAfter) === 0) {
|
||||
echo "✅ CORRECCIÓN EXITOSA\n";
|
||||
} else {
|
||||
echo "⚠️ REQUIERE REVISIÓN\n";
|
||||
}
|
||||
echo "─────────────────────────────────\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
} elseif ($mode === 'fix') {
|
||||
echo "MODO: CORRECCIÓN (GUARDANDO CAMBIOS)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 50;
|
||||
$offset = 0;
|
||||
$fixed_count = 0;
|
||||
$error_count = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT ID, post_content FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''
|
||||
ORDER BY ID LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['post_content']);
|
||||
|
||||
if (!empty($issues)) {
|
||||
$fixResult = fixMalformedLists($row['post_content']);
|
||||
|
||||
if ($fixResult['fixed']) {
|
||||
$stmt = $conn->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
|
||||
$stmt->bind_param("si", $fixResult['html'], $row['ID']);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$fixed_count++;
|
||||
echo "[ID: {$row['ID']}] ✓ Corregido ({$fixResult['changes']} cambios)\n";
|
||||
} else {
|
||||
$error_count++;
|
||||
echo "[ID: {$row['ID']}] ✗ Error al guardar\n";
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 500 == 0) {
|
||||
echo "Procesados: $offset/$total (corregidos: $fixed_count)\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts corregidos: $fixed_count\n";
|
||||
echo " Errores: $error_count\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "\n✓ Proceso completado.\n";
|
||||
@@ -1,307 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de Diagnóstico: Listas HTML Mal Formadas
|
||||
*
|
||||
* PROPÓSITO: Identificar posts con estructura de listas inválida
|
||||
* - <ul> conteniendo <ul> como hijo directo (en lugar de dentro de <li>)
|
||||
* - <ol> conteniendo <ol> como hijo directo
|
||||
*
|
||||
* BASE DE DATOS: preciosunitarios_seo
|
||||
* TABLA: datos_seo_pagina
|
||||
* CAMPO: html
|
||||
*
|
||||
* IMPORTANTE: Este script SOLO LEE, no modifica ningún dato.
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since Phase 4.4 Accessibility
|
||||
*/
|
||||
|
||||
// Configuración de errores para debugging
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(300); // 5 minutos máximo
|
||||
|
||||
// Credenciales de base de datos (ajustar según servidor)
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_seo',
|
||||
'username' => 'root', // Cambiar en producción
|
||||
'password' => '', // Cambiar en producción
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
// Patrones regex para detectar listas mal formadas
|
||||
$malformed_patterns = [
|
||||
// <ul> seguido directamente de <ul> (sin estar dentro de <li>)
|
||||
'ul_direct_ul' => '/<ul[^>]*>\s*(?:<li[^>]*>.*?<\/li>\s*)*<ul/is',
|
||||
|
||||
// Patrón más específico: </li> seguido de <ul> (hermanos en lugar de anidados)
|
||||
'li_sibling_ul' => '/<\/li>\s*<ul[^>]*>/is',
|
||||
|
||||
// <ol> seguido directamente de <ol>
|
||||
'ol_direct_ol' => '/<ol[^>]*>\s*(?:<li[^>]*>.*?<\/li>\s*)*<ol/is',
|
||||
|
||||
// </li> seguido de <ol> (hermanos)
|
||||
'li_sibling_ol' => '/<\/li>\s*<ol[^>]*>/is',
|
||||
];
|
||||
|
||||
/**
|
||||
* Conectar a la base de datos
|
||||
*/
|
||||
function connectDatabase(array $config): ?mysqli {
|
||||
$conn = new mysqli(
|
||||
$config['host'],
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['database']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo "Error de conexión: " . $conn->connect_error . "\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
$conn->set_charset($config['charset']);
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analizar HTML en busca de listas mal formadas
|
||||
*/
|
||||
function analyzeMalformedLists(string $html, array $patterns): array {
|
||||
$issues = [];
|
||||
|
||||
foreach ($patterns as $pattern_name => $pattern) {
|
||||
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
$position = $match[1];
|
||||
$context = getContextAroundPosition($html, $position, 100);
|
||||
|
||||
$issues[] = [
|
||||
'type' => $pattern_name,
|
||||
'position' => $position,
|
||||
'context' => $context
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener contexto alrededor de una posición
|
||||
*/
|
||||
function getContextAroundPosition(string $html, int $position, int $length = 100): string {
|
||||
$start = max(0, $position - $length);
|
||||
$end = min(strlen($html), $position + $length);
|
||||
|
||||
$context = substr($html, $start, $end - $start);
|
||||
|
||||
// Limpiar para mostrar
|
||||
$context = preg_replace('/\s+/', ' ', $context);
|
||||
$context = htmlspecialchars($context);
|
||||
|
||||
if ($start > 0) {
|
||||
$context = '...' . $context;
|
||||
}
|
||||
if ($end < strlen($html)) {
|
||||
$context .= '...';
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contar total de listas en el HTML
|
||||
*/
|
||||
function countListElements(string $html): array {
|
||||
$ul_count = preg_match_all('/<ul[^>]*>/i', $html);
|
||||
$ol_count = preg_match_all('/<ol[^>]*>/i', $html);
|
||||
$li_count = preg_match_all('/<li[^>]*>/i', $html);
|
||||
|
||||
return [
|
||||
'ul' => $ul_count,
|
||||
'ol' => $ol_count,
|
||||
'li' => $li_count
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EJECUCIÓN PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " DIAGNÓSTICO: Listas HTML Mal Formadas\n";
|
||||
echo " Base de datos: {$db_config['database']}\n";
|
||||
echo " Tabla: datos_seo_pagina\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Conectar
|
||||
$conn = connectDatabase($db_config);
|
||||
if (!$conn) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Obtener estructura de la tabla
|
||||
echo "Verificando estructura de tabla...\n";
|
||||
$result = $conn->query("DESCRIBE datos_seo_pagina");
|
||||
if ($result) {
|
||||
echo "Columnas encontradas:\n";
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
echo " - {$row['Field']} ({$row['Type']})\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Contar registros totales
|
||||
$result = $conn->query("SELECT COUNT(*) as total FROM datos_seo_pagina WHERE html IS NOT NULL AND html != ''");
|
||||
$total = $result->fetch_assoc()['total'];
|
||||
echo "Total de registros con HTML: {$total}\n\n";
|
||||
|
||||
// Procesar en lotes
|
||||
$batch_size = 100;
|
||||
$offset = 0;
|
||||
$affected_posts = [];
|
||||
$total_issues = 0;
|
||||
$processed = 0;
|
||||
|
||||
echo "Iniciando análisis...\n";
|
||||
echo "─────────────────────────────────────────────\n";
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id
|
||||
LIMIT {$batch_size} OFFSET {$offset}";
|
||||
|
||||
$result = $conn->query($query);
|
||||
|
||||
if (!$result) {
|
||||
echo "Error en consulta: " . $conn->error . "\n";
|
||||
break;
|
||||
}
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$processed++;
|
||||
$id = $row['id'];
|
||||
$url = $row['page'] ?? 'N/A';
|
||||
$html = $row['html'];
|
||||
|
||||
$issues = analyzeMalformedLists($html, $malformed_patterns);
|
||||
|
||||
if (!empty($issues)) {
|
||||
$list_counts = countListElements($html);
|
||||
|
||||
$affected_posts[] = [
|
||||
'id' => $id,
|
||||
'url' => $url,
|
||||
'issues' => $issues,
|
||||
'list_counts' => $list_counts
|
||||
];
|
||||
|
||||
$total_issues += count($issues);
|
||||
|
||||
// Mostrar progreso para posts afectados
|
||||
echo "\n[ID: {$id}] " . count($issues) . " problema(s) encontrado(s)\n";
|
||||
echo "URL: {$url}\n";
|
||||
echo "Listas: UL={$list_counts['ul']}, OL={$list_counts['ol']}, LI={$list_counts['li']}\n";
|
||||
|
||||
foreach ($issues as $idx => $issue) {
|
||||
echo " Problema " . ($idx + 1) . ": {$issue['type']} (pos: {$issue['position']})\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Mostrar progreso cada 500 registros
|
||||
if ($processed % 500 == 0) {
|
||||
echo "\rProcesados: {$processed}/{$total}...";
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch_size;
|
||||
}
|
||||
|
||||
echo "\n\n";
|
||||
echo "==============================================\n";
|
||||
echo " RESUMEN DEL ANÁLISIS\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
echo "Registros analizados: {$processed}\n";
|
||||
echo "Posts con problemas: " . count($affected_posts) . "\n";
|
||||
echo "Total de incidencias: {$total_issues}\n\n";
|
||||
|
||||
if (count($affected_posts) > 0) {
|
||||
echo "─────────────────────────────────────────────\n";
|
||||
echo "DETALLE DE POSTS AFECTADOS\n";
|
||||
echo "─────────────────────────────────────────────\n\n";
|
||||
|
||||
// Agrupar por tipo de problema
|
||||
$by_type = [];
|
||||
foreach ($affected_posts as $post) {
|
||||
foreach ($post['issues'] as $issue) {
|
||||
$type = $issue['type'];
|
||||
if (!isset($by_type[$type])) {
|
||||
$by_type[$type] = [];
|
||||
}
|
||||
$by_type[$type][] = $post['id'];
|
||||
}
|
||||
}
|
||||
|
||||
echo "Por tipo de problema:\n";
|
||||
foreach ($by_type as $type => $ids) {
|
||||
$unique_ids = array_unique($ids);
|
||||
echo " - {$type}: " . count($unique_ids) . " posts\n";
|
||||
}
|
||||
|
||||
echo "\n─────────────────────────────────────────────\n";
|
||||
echo "LISTA DE IDs AFECTADOS (para revisión manual)\n";
|
||||
echo "─────────────────────────────────────────────\n\n";
|
||||
|
||||
$ids_list = array_column($affected_posts, 'id');
|
||||
echo "IDs: " . implode(', ', $ids_list) . "\n";
|
||||
|
||||
// Generar archivo de reporte
|
||||
$report_file = __DIR__ . '/malformed-lists-report-' . date('Ymd-His') . '.json';
|
||||
$report_data = [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'database' => $db_config['database'],
|
||||
'table' => 'datos_seo_pagina',
|
||||
'total_analyzed' => $processed,
|
||||
'total_affected' => count($affected_posts),
|
||||
'total_issues' => $total_issues,
|
||||
'by_type' => array_map(function($ids) {
|
||||
return array_values(array_unique($ids));
|
||||
}, $by_type),
|
||||
'affected_posts' => $affected_posts
|
||||
];
|
||||
|
||||
if (file_put_contents($report_file, json_encode($report_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||
echo "\n✓ Reporte JSON guardado en:\n {$report_file}\n";
|
||||
}
|
||||
|
||||
// Muestra de contexto para análisis
|
||||
echo "\n─────────────────────────────────────────────\n";
|
||||
echo "MUESTRA DE CONTEXTO (primeros 3 posts)\n";
|
||||
echo "─────────────────────────────────────────────\n\n";
|
||||
|
||||
$sample = array_slice($affected_posts, 0, 3);
|
||||
foreach ($sample as $post) {
|
||||
echo "POST ID: {$post['id']}\n";
|
||||
echo "URL: {$post['url']}\n";
|
||||
foreach ($post['issues'] as $idx => $issue) {
|
||||
echo " [{$issue['type']}]\n";
|
||||
echo " Contexto: {$issue['context']}\n\n";
|
||||
}
|
||||
echo "───────────────────────\n";
|
||||
}
|
||||
|
||||
} else {
|
||||
echo "✓ No se encontraron listas mal formadas.\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "\n✓ Análisis completado.\n";
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de PRUEBA - Muestra corrección propuesta sin aplicarla
|
||||
*
|
||||
* IMPORTANTE: Este script SOLO MUESTRA, no modifica nada.
|
||||
*/
|
||||
|
||||
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
|
||||
$conn->set_charset("utf8mb4");
|
||||
|
||||
echo "========================================\n";
|
||||
echo "ANÁLISIS DE CORRECCIÓN PROPUESTA\n";
|
||||
echo "========================================\n\n";
|
||||
|
||||
// Patrón que encuentra: </li></ul><li>TEXTO</li><ul>
|
||||
// Este patrón captura:
|
||||
// - $1: </li> inicial (con espacios)
|
||||
// - $2: espacios entre </ul> y <li>
|
||||
// - $3: contenido del <li> (ej: <strong>Texto</strong>)
|
||||
// - $4: espacios entre </li> y <ul>
|
||||
|
||||
$pattern = '#(</li>\s*)</ul>(\s*)<li>(.*?)</li>(\s*)<ul>#is';
|
||||
$replacement = '$1<li>$3$4<ul>';
|
||||
|
||||
echo "PATRÓN A BUSCAR:\n";
|
||||
echo " </li>\\s*</ul>\\s*<li>CONTENIDO</li>\\s*<ul>\n\n";
|
||||
|
||||
echo "REEMPLAZO:\n";
|
||||
echo " </li><li>CONTENIDO<ul>\n\n";
|
||||
|
||||
// Obtener HTML del post ID 3
|
||||
$result = $conn->query("SELECT id, page, html FROM datos_seo_pagina WHERE id = 3");
|
||||
$row = $result->fetch_assoc();
|
||||
$html = $row["html"];
|
||||
$page = $row["page"];
|
||||
|
||||
echo "PROBANDO CON POST ID 3:\n";
|
||||
echo "URL: $page\n";
|
||||
echo "────────────────────────────\n\n";
|
||||
|
||||
// Encontrar todas las ocurrencias
|
||||
preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
echo "Ocurrencias encontradas: " . count($matches) . "\n\n";
|
||||
|
||||
// Mostrar cada ocurrencia y su corrección propuesta
|
||||
foreach (array_slice($matches, 0, 3) as $idx => $match) {
|
||||
$full_match = $match[0][0];
|
||||
$position = $match[0][1];
|
||||
|
||||
echo "[$idx] Posición: $position\n";
|
||||
echo "ANTES:\n";
|
||||
echo htmlspecialchars($full_match) . "\n\n";
|
||||
|
||||
$fixed = preg_replace($pattern, $replacement, $full_match);
|
||||
echo "DESPUÉS:\n";
|
||||
echo htmlspecialchars($fixed) . "\n";
|
||||
echo "────────────────────────────\n\n";
|
||||
}
|
||||
|
||||
// Aplicar corrección en memoria y contar diferencia
|
||||
$html_fixed = preg_replace($pattern, $replacement, $html);
|
||||
|
||||
$before = preg_match_all($pattern, $html);
|
||||
$after = preg_match_all($pattern, $html_fixed);
|
||||
|
||||
echo "========================================\n";
|
||||
echo "RESUMEN DE CORRECCIÓN (sin aplicar):\n";
|
||||
echo "========================================\n";
|
||||
echo "Ocurrencias ANTES: $before\n";
|
||||
echo "Ocurrencias DESPUÉS: $after\n";
|
||||
echo "Reducción: " . ($before - $after) . "\n\n";
|
||||
|
||||
// Verificar que la estructura es válida después de la corrección
|
||||
$ul_count_before = substr_count($html, '<ul');
|
||||
$ul_count_after = substr_count($html_fixed, '<ul');
|
||||
echo "Tags <ul> antes: $ul_count_before\n";
|
||||
echo "Tags <ul> después: $ul_count_after\n";
|
||||
|
||||
$li_count_before = substr_count($html, '<li');
|
||||
$li_count_after = substr_count($html_fixed, '<li');
|
||||
echo "Tags <li> antes: $li_count_before\n";
|
||||
echo "Tags <li> después: $li_count_after\n";
|
||||
|
||||
echo "\n========================================\n";
|
||||
echo "NOTA: Este patrón elimina el </ul> prematuro\n";
|
||||
echo "pero NO agrega el </li> faltante al final.\n";
|
||||
echo "Se necesita un segundo paso para balancear.\n";
|
||||
echo "========================================\n";
|
||||
|
||||
$conn->close();
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Prueba corrección en casos específicos variados
|
||||
*/
|
||||
|
||||
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
|
||||
$conn->set_charset("utf8mb4");
|
||||
|
||||
// IDs a probar (casos variados)
|
||||
$test_ids = [20, 23, 65, 377, 98, 107, 144];
|
||||
|
||||
function detectIssues($html) {
|
||||
$issues = [];
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument("1.0", "UTF-8");
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><div id="w">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ["li", "script", "template"];
|
||||
foreach (["ul", "ol"] as $tag) {
|
||||
foreach ($doc->getElementsByTagName($tag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$childTag = strtolower($child->nodeName);
|
||||
if (!in_array($childTag, $validChildren)) {
|
||||
$issues[] = "<$tag> contiene <$childTag>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $issues;
|
||||
}
|
||||
|
||||
function fixMalformedLists($html) {
|
||||
$result = ['fixed' => false, 'html' => $html, 'changes' => 0];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument("1.0", "UTF-8");
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><div id="w">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
|
||||
|
||||
$changes = 0;
|
||||
$validChildren = ["li", "script", "template"];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$nodesToProcess = [];
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
$prevLi->appendChild($node);
|
||||
$changes++;
|
||||
} else {
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$wrapper = $doc->getElementById('w');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
echo "=====================================================\n";
|
||||
echo " PRUEBA DE CORRECCIÓN EN CASOS VARIADOS\n";
|
||||
echo "=====================================================\n\n";
|
||||
|
||||
$ids_str = implode(',', $test_ids);
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE id IN ($ids_str)";
|
||||
$result = $conn->query($query);
|
||||
|
||||
$all_passed = true;
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$id = $row['id'];
|
||||
$url = $row['page'];
|
||||
$html = $row['html'];
|
||||
|
||||
echo "─────────────────────────────────────────────────\n";
|
||||
echo "POST ID: $id\n";
|
||||
echo "URL: $url\n\n";
|
||||
|
||||
// Detectar problemas antes
|
||||
$issues_before = detectIssues($html);
|
||||
echo "ANTES:\n";
|
||||
echo " Problemas: " . count($issues_before) . "\n";
|
||||
$unique_types = array_unique($issues_before);
|
||||
foreach ($unique_types as $type) {
|
||||
echo " - $type\n";
|
||||
}
|
||||
|
||||
// Aplicar corrección
|
||||
$fixResult = fixMalformedLists($html);
|
||||
|
||||
// Detectar problemas después
|
||||
$issues_after = detectIssues($fixResult['html']);
|
||||
|
||||
echo "\nDESPUÉS:\n";
|
||||
echo " Cambios aplicados: {$fixResult['changes']}\n";
|
||||
echo " Problemas restantes: " . count($issues_after) . "\n";
|
||||
|
||||
if (count($issues_after) > 0) {
|
||||
echo " ⚠️ Problemas NO resueltos:\n";
|
||||
foreach (array_unique($issues_after) as $type) {
|
||||
echo " - $type\n";
|
||||
}
|
||||
$all_passed = false;
|
||||
}
|
||||
|
||||
// Verificar integridad del HTML
|
||||
$tags_before = [
|
||||
'ul' => substr_count($html, '<ul'),
|
||||
'ol' => substr_count($html, '<ol'),
|
||||
'li' => substr_count($html, '<li'),
|
||||
];
|
||||
$tags_after = [
|
||||
'ul' => substr_count($fixResult['html'], '<ul'),
|
||||
'ol' => substr_count($fixResult['html'], '<ol'),
|
||||
'li' => substr_count($fixResult['html'], '<li'),
|
||||
];
|
||||
|
||||
echo "\nINTEGRIDAD DE TAGS:\n";
|
||||
echo " <ul>: {$tags_before['ul']} → {$tags_after['ul']} ";
|
||||
echo ($tags_before['ul'] === $tags_after['ul'] ? "✓" : "⚠️ CAMBIÓ") . "\n";
|
||||
echo " <ol>: {$tags_before['ol']} → {$tags_after['ol']} ";
|
||||
echo ($tags_before['ol'] === $tags_after['ol'] ? "✓" : "⚠️ CAMBIÓ") . "\n";
|
||||
echo " <li>: {$tags_before['li']} → {$tags_after['li']} ";
|
||||
echo ($tags_before['li'] === $tags_after['li'] ? "✓" : "⚠️ CAMBIÓ") . "\n";
|
||||
|
||||
// Resultado
|
||||
if (count($issues_after) === 0 &&
|
||||
$tags_before['ul'] === $tags_after['ul'] &&
|
||||
$tags_before['ol'] === $tags_after['ol']) {
|
||||
echo "\n✅ RESULTADO: CORRECCIÓN EXITOSA\n";
|
||||
} else {
|
||||
echo "\n❌ RESULTADO: REQUIERE REVISIÓN\n";
|
||||
$all_passed = false;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=====================================================\n";
|
||||
if ($all_passed) {
|
||||
echo "✅ TODOS LOS CASOS PASARON LA PRUEBA\n";
|
||||
} else {
|
||||
echo "⚠️ ALGUNOS CASOS REQUIEREN REVISIÓN\n";
|
||||
}
|
||||
echo "=====================================================\n";
|
||||
|
||||
$conn->close();
|
||||
@@ -1,347 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Validador de Correcciones - Genera archivos HTML para revisión visual
|
||||
*
|
||||
* PROPÓSITO: Crear archivos comparativos ANTES/DESPUÉS para validar
|
||||
* que la corrección no rompe el contenido.
|
||||
*
|
||||
* USO: php validate-fix-lists.php
|
||||
*
|
||||
* GENERA:
|
||||
* /tmp/list-fix-validation/
|
||||
* ├── post_ID_before.html
|
||||
* ├── post_ID_after.html
|
||||
* └── comparison_report.html
|
||||
*
|
||||
* @package ROI_Theme
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_seo',
|
||||
'username' => 'preciosunitarios_seo',
|
||||
'password' => 'ACl%EEFd=V-Yvb??',
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
$output_dir = '/tmp/list-fix-validation';
|
||||
$sample_size = 5;
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " VALIDADOR DE CORRECCIONES\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Crear directorio de salida
|
||||
if (!is_dir($output_dir)) {
|
||||
mkdir($output_dir, 0755, true);
|
||||
}
|
||||
|
||||
// Limpiar archivos anteriores
|
||||
array_map('unlink', glob("$output_dir/*.html"));
|
||||
|
||||
/**
|
||||
* Detectar problemas en HTML
|
||||
*/
|
||||
function detectIssues(string $html): array {
|
||||
$issues = [];
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach (['ul', 'ol'] as $listTag) {
|
||||
foreach ($doc->getElementsByTagName($listTag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = "<$listTag> contiene <$tagName>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregir listas mal formadas
|
||||
*/
|
||||
function fixMalformedLists(string $html): array {
|
||||
$result = ['fixed' => false, 'html' => $html, 'changes' => 0, 'details' => []];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
|
||||
|
||||
$changes = 0;
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$nodesToProcess = [];
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
$prevLi->appendChild($node);
|
||||
$result['details'][] = "Movido <$tagName> dentro del <li> anterior";
|
||||
$changes++;
|
||||
} else {
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$result['details'][] = "Envuelto <$tagName> en nuevo <li>";
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$wrapper = $doc->getElementById('temp-wrapper');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar HTML wrapper para visualización
|
||||
*/
|
||||
function wrapForVisualization(string $content, string $title, string $status): string {
|
||||
$statusColor = $status === 'error' ? '#dc3545' : '#28a745';
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$title</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; line-height: 1.6; }
|
||||
.status { padding: 10px 20px; background: $statusColor; color: white; border-radius: 4px; margin-bottom: 20px; }
|
||||
.content { border: 1px solid #ddd; padding: 20px; border-radius: 4px; background: #fafafa; }
|
||||
ul, ol { background: #fff3cd; padding: 15px 15px 15px 35px; border-left: 4px solid #ffc107; margin: 10px 0; }
|
||||
li { background: #d4edda; padding: 5px 10px; margin: 5px 0; border-left: 3px solid #28a745; }
|
||||
h1, h2, h3, h4, h5, h6 { color: #333; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status">$status</div>
|
||||
<div class="content">
|
||||
$content
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
// Conectar a DB
|
||||
$conn = new mysqli($db_config['host'], $db_config['username'], $db_config['password'], $db_config['database']);
|
||||
$conn->set_charset($db_config['charset']);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Error de conexión: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Buscar posts con problemas
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE html IS NOT NULL AND html != '' ORDER BY id LIMIT 500";
|
||||
$result = $conn->query($query);
|
||||
|
||||
$samples = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
if (!empty($issues) && count($samples) < $sample_size) {
|
||||
$samples[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Encontrados " . count($samples) . " posts con problemas para validar\n\n";
|
||||
|
||||
$comparison_data = [];
|
||||
|
||||
foreach ($samples as $idx => $post) {
|
||||
$id = $post['id'];
|
||||
$url = $post['page'];
|
||||
$html_before = $post['html'];
|
||||
|
||||
echo "─────────────────────────────────\n";
|
||||
echo "POST $id: $url\n";
|
||||
|
||||
// Detectar problemas antes
|
||||
$issues_before = detectIssues($html_before);
|
||||
echo " Problemas ANTES: " . count($issues_before) . "\n";
|
||||
|
||||
// Aplicar corrección
|
||||
$fixResult = fixMalformedLists($html_before);
|
||||
$html_after = $fixResult['html'];
|
||||
|
||||
// Detectar problemas después
|
||||
$issues_after = detectIssues($html_after);
|
||||
echo " Problemas DESPUÉS: " . count($issues_after) . "\n";
|
||||
echo " Cambios aplicados: " . $fixResult['changes'] . "\n";
|
||||
|
||||
// Guardar archivos HTML
|
||||
$file_before = "$output_dir/post_{$id}_BEFORE.html";
|
||||
$file_after = "$output_dir/post_{$id}_AFTER.html";
|
||||
|
||||
file_put_contents($file_before, wrapForVisualization(
|
||||
$html_before,
|
||||
"Post $id - ANTES (con errores)",
|
||||
"ANTES: " . count($issues_before) . " problemas de listas"
|
||||
));
|
||||
|
||||
file_put_contents($file_after, wrapForVisualization(
|
||||
$html_after,
|
||||
"Post $id - DESPUÉS (corregido)",
|
||||
"DESPUÉS: " . count($issues_after) . " problemas - " . $fixResult['changes'] . " correcciones aplicadas"
|
||||
));
|
||||
|
||||
echo " ✓ Archivos generados:\n";
|
||||
echo " - $file_before\n";
|
||||
echo " - $file_after\n";
|
||||
|
||||
// Guardar datos para reporte
|
||||
$comparison_data[] = [
|
||||
'id' => $id,
|
||||
'url' => $url,
|
||||
'issues_before' => count($issues_before),
|
||||
'issues_after' => count($issues_after),
|
||||
'changes' => $fixResult['changes'],
|
||||
'file_before' => "post_{$id}_BEFORE.html",
|
||||
'file_after' => "post_{$id}_AFTER.html"
|
||||
];
|
||||
}
|
||||
|
||||
// Generar reporte comparativo
|
||||
$report_html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Reporte de Validación - Corrección de Listas</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { padding: 12px; text-align: left; border: 1px solid #ddd; }
|
||||
th { background: #007bff; color: white; }
|
||||
tr:nth-child(even) { background: #f8f9fa; }
|
||||
.success { color: #28a745; font-weight: bold; }
|
||||
.warning { color: #ffc107; font-weight: bold; }
|
||||
.error { color: #dc3545; font-weight: bold; }
|
||||
a { color: #007bff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.instructions { background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Reporte de Validación - Corrección de Listas HTML</h1>
|
||||
|
||||
<div class="instructions">
|
||||
<strong>Instrucciones:</strong>
|
||||
<ol>
|
||||
<li>Abre cada par de archivos (ANTES/DESPUÉS) en el navegador</li>
|
||||
<li>Verifica que el contenido se muestre correctamente</li>
|
||||
<li>Las listas (fondo amarillo) deben contener solo items (fondo verde)</li>
|
||||
<li>Si todo se ve bien, la corrección es segura</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>URL</th>
|
||||
<th>Problemas Antes</th>
|
||||
<th>Problemas Después</th>
|
||||
<th>Cambios</th>
|
||||
<th>Archivos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
HTML;
|
||||
|
||||
foreach ($comparison_data as $data) {
|
||||
$status_class = $data['issues_after'] == 0 ? 'success' : ($data['issues_after'] < $data['issues_before'] ? 'warning' : 'error');
|
||||
|
||||
$report_html .= <<<HTML
|
||||
<tr>
|
||||
<td>{$data['id']}</td>
|
||||
<td><a href="{$data['url']}" target="_blank">{$data['url']}</a></td>
|
||||
<td class="error">{$data['issues_before']}</td>
|
||||
<td class="$status_class">{$data['issues_after']}</td>
|
||||
<td>{$data['changes']}</td>
|
||||
<td>
|
||||
<a href="{$data['file_before']}" target="_blank">ANTES</a> |
|
||||
<a href="{$data['file_after']}" target="_blank">DESPUÉS</a>
|
||||
</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$report_html .= <<<HTML
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><strong>Generado:</strong> {$_SERVER['REQUEST_TIME_FLOAT']}</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$report_file = "$output_dir/comparison_report.html";
|
||||
file_put_contents($report_file, $report_html);
|
||||
|
||||
echo "\n─────────────────────────────────\n";
|
||||
echo "REPORTE GENERADO:\n";
|
||||
echo " $report_file\n\n";
|
||||
echo "Para revisar, descarga el directorio:\n";
|
||||
echo " scp -r VPSContabo:$output_dir ./validation/\n\n";
|
||||
|
||||
$conn->close();
|
||||
echo "✓ Validación completada.\n";
|
||||
@@ -20,7 +20,7 @@ final class MigratePageVisibilityService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ejecuta la migración para todos los componentes
|
||||
* Ejecuta la migracion para todos los componentes
|
||||
*
|
||||
* @return array{created: int, skipped: int}
|
||||
*/
|
||||
@@ -37,10 +37,10 @@ final class MigratePageVisibilityService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Usar constante compartida (DRY)
|
||||
// Usar defaults especificos por componente si existen
|
||||
$this->visibilityRepository->createDefaultVisibility(
|
||||
$componentName,
|
||||
VisibilityDefaults::DEFAULT_VISIBILITY
|
||||
VisibilityDefaults::getForComponent($componentName)
|
||||
);
|
||||
$created++;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ final class WordPressPageTypeDetector implements PageTypeDetectorInterface
|
||||
|
||||
public function isHome(): bool
|
||||
{
|
||||
return is_front_page();
|
||||
// is_front_page() = pagina de inicio configurada
|
||||
// is_home() = pagina de posts (blog)
|
||||
// Ambas cuentan como "home" para visibilidad
|
||||
return is_front_page() || is_home();
|
||||
}
|
||||
|
||||
public function isPost(): bool
|
||||
|
||||
377
Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php
Normal file
377
Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Implementacion de PostGridShortcodeRendererInterface
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML y CSS del shortcode [roi_post_grid].
|
||||
* No construye queries ni obtiene settings de BD.
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Ui
|
||||
*/
|
||||
final class PostGridShortcodeRenderer implements PostGridShortcodeRendererInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function render(\WP_Query $query, array $settings, array $options): string
|
||||
{
|
||||
if (!$query->have_posts()) {
|
||||
return $this->renderNoPostsMessage($settings, $options);
|
||||
}
|
||||
|
||||
$css = $this->generateCSS($settings, $options);
|
||||
$html = $this->buildHTML($query, $settings, $options);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
private function renderNoPostsMessage(array $settings, array $options): string
|
||||
{
|
||||
$colors = $settings['colors'] ?? [];
|
||||
$message = 'No se encontraron publicaciones';
|
||||
|
||||
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$textColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
|
||||
$selector = $this->getSelector($options);
|
||||
|
||||
$css = $this->cssGenerator->generate("{$selector} .no-posts", [
|
||||
'background-color' => $bgColor,
|
||||
'color' => $textColor,
|
||||
'border' => "1px solid {$borderColor}",
|
||||
'border-radius' => '0.5rem',
|
||||
'padding' => '2rem',
|
||||
'text-align' => 'center',
|
||||
]);
|
||||
|
||||
$containerClass = $this->getContainerClass($options);
|
||||
|
||||
return sprintf(
|
||||
"<style>%s</style>\n<div class=\"%s\"><div class=\"no-posts\"><p class=\"mb-0\">%s</p></div></div>",
|
||||
$css,
|
||||
esc_attr($containerClass),
|
||||
esc_html($message)
|
||||
);
|
||||
}
|
||||
|
||||
private function getSelector(array $options): string
|
||||
{
|
||||
$id = $options['id'] ?? '';
|
||||
return !empty($id)
|
||||
? ".roi-post-grid-shortcode-{$id}"
|
||||
: '.roi-post-grid-shortcode';
|
||||
}
|
||||
|
||||
private function getContainerClass(array $options): string
|
||||
{
|
||||
$id = $options['id'] ?? '';
|
||||
$customClass = $options['class'] ?? '';
|
||||
|
||||
$class = !empty($id)
|
||||
? "roi-post-grid-shortcode roi-post-grid-shortcode-{$id}"
|
||||
: 'roi-post-grid-shortcode';
|
||||
|
||||
if (!empty($customClass)) {
|
||||
$class .= ' ' . sanitize_html_class($customClass);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
private function generateCSS(array $settings, array $options): string
|
||||
{
|
||||
$colors = $settings['colors'] ?? [];
|
||||
$spacing = $settings['spacing'] ?? [];
|
||||
$effects = $settings['visual_effects'] ?? [];
|
||||
$typography = $settings['typography'] ?? [];
|
||||
|
||||
$selector = $this->getSelector($options);
|
||||
$cssRules = [];
|
||||
|
||||
// Colores
|
||||
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
|
||||
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
|
||||
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
|
||||
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$metaColor = $colors['meta_color'] ?? '#9ca3af';
|
||||
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
|
||||
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
|
||||
|
||||
// Spacing
|
||||
$gridGap = $spacing['grid_gap'] ?? '1.5rem';
|
||||
$cardPadding = $spacing['card_padding'] ?? '1.25rem';
|
||||
|
||||
// Visual effects
|
||||
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
|
||||
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
|
||||
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
|
||||
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
|
||||
|
||||
// Typography
|
||||
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
|
||||
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
|
||||
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
|
||||
$metaSize = $typography['meta_size'] ?? '0.8rem';
|
||||
|
||||
// Container
|
||||
$cssRules[] = $this->cssGenerator->generate($selector, [
|
||||
'margin-bottom' => '2rem',
|
||||
]);
|
||||
|
||||
// Row
|
||||
$cssRules[] = $this->cssGenerator->generate("{$selector} .row", [
|
||||
'row-gap' => $gridGap,
|
||||
]);
|
||||
|
||||
// Card
|
||||
$cssRules[] = "{$selector} .card {
|
||||
background: {$cardBgColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
border-radius: {$cardBorderRadius};
|
||||
box-shadow: {$cardShadow};
|
||||
transition: {$cardTransition};
|
||||
height: 100%;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .card:hover {
|
||||
background: {$cardHoverBgColor};
|
||||
border-color: {$cardHoverBorderColor};
|
||||
box-shadow: {$cardHoverShadow};
|
||||
transform: translateY(-2px);
|
||||
}";
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate("{$selector} .card-body", [
|
||||
'padding' => $cardPadding,
|
||||
]);
|
||||
|
||||
$cssRules[] = "{$selector} .card-img-top {
|
||||
border-radius: {$cardBorderRadius} {$cardBorderRadius} 0 0;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .card-title {
|
||||
color: {$cardTitleColor};
|
||||
font-size: {$cardTitleSize};
|
||||
font-weight: {$cardTitleWeight};
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.75rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} a:hover .card-title {
|
||||
color: {$cardHoverBorderColor};
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .card-text {
|
||||
color: {$excerptColor};
|
||||
font-size: {$excerptSize};
|
||||
line-height: 1.6;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .post-meta {
|
||||
color: {$metaColor};
|
||||
font-size: {$metaSize};
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .post-category {
|
||||
background: {$categoryBgColor};
|
||||
color: {$categoryTextColor};
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}";
|
||||
|
||||
return implode("\n", $cssRules);
|
||||
}
|
||||
|
||||
private function buildHTML(\WP_Query $query, array $settings, array $options): string
|
||||
{
|
||||
$columns = (int) ($options['columns'] ?? 3);
|
||||
$showThumbnail = $this->toBool($options['show_thumbnail'] ?? true);
|
||||
$showExcerpt = $this->toBool($options['show_excerpt'] ?? true);
|
||||
$showMeta = $this->toBool($options['show_meta'] ?? true);
|
||||
$showCategories = $this->toBool($options['show_categories'] ?? true);
|
||||
$excerptLength = (int) ($options['excerpt_length'] ?? 20);
|
||||
$showPagination = $this->toBool($options['show_pagination'] ?? false);
|
||||
|
||||
$containerClass = $this->getContainerClass($options);
|
||||
$colClass = $this->getColumnClass($columns);
|
||||
|
||||
$html = sprintf('<div class="%s">', esc_attr($containerClass));
|
||||
$html .= '<div class="row">';
|
||||
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$html .= $this->buildCardHTML(
|
||||
$colClass,
|
||||
$showThumbnail,
|
||||
$showExcerpt,
|
||||
$showMeta,
|
||||
$showCategories,
|
||||
$excerptLength
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
if ($showPagination && $query->max_num_pages > 1) {
|
||||
$html .= $this->buildPaginationHTML($query, $options);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getColumnClass(int $columns): string
|
||||
{
|
||||
return match ($columns) {
|
||||
1 => 'col-12',
|
||||
2 => 'col-12 col-md-6',
|
||||
4 => 'col-12 col-md-6 col-lg-3',
|
||||
default => 'col-12 col-md-6 col-lg-4',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildCardHTML(
|
||||
string $colClass,
|
||||
bool $showThumbnail,
|
||||
bool $showExcerpt,
|
||||
bool $showMeta,
|
||||
bool $showCategories,
|
||||
int $excerptLength
|
||||
): string {
|
||||
$permalink = get_permalink();
|
||||
$title = get_the_title();
|
||||
|
||||
$html = sprintf('<div class="%s mb-4">', esc_attr($colClass));
|
||||
$html .= sprintf('<a href="%s" class="text-decoration-none">', esc_url($permalink));
|
||||
$html .= '<div class="card h-100">';
|
||||
|
||||
if ($showThumbnail) {
|
||||
$html .= $this->buildImageHTML();
|
||||
}
|
||||
|
||||
$html .= '<div class="card-body">';
|
||||
|
||||
if ($showCategories) {
|
||||
$html .= $this->buildCategoriesHTML();
|
||||
}
|
||||
|
||||
$html .= sprintf('<h3 class="card-title">%s</h3>', esc_html($title));
|
||||
|
||||
if ($showMeta) {
|
||||
$html .= $this->buildMetaHTML();
|
||||
}
|
||||
|
||||
if ($showExcerpt) {
|
||||
$html .= $this->buildExcerptHTML($excerptLength);
|
||||
}
|
||||
|
||||
$html .= '</div></div></a></div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildImageHTML(): string
|
||||
{
|
||||
if (has_post_thumbnail()) {
|
||||
return get_the_post_thumbnail(null, 'medium_large', [
|
||||
'class' => 'card-img-top',
|
||||
'loading' => 'lazy'
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildCategoriesHTML(): string
|
||||
{
|
||||
$categories = get_the_category();
|
||||
if (empty($categories)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="post-categories mb-2">';
|
||||
foreach (array_slice($categories, 0, 2) as $category) {
|
||||
$html .= sprintf(
|
||||
'<span class="post-category">%s</span>',
|
||||
esc_html($category->name)
|
||||
);
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMetaHTML(): string
|
||||
{
|
||||
return sprintf(
|
||||
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
|
||||
esc_html(get_the_date()),
|
||||
esc_html(get_the_author())
|
||||
);
|
||||
}
|
||||
|
||||
private function buildExcerptHTML(int $length): string
|
||||
{
|
||||
$excerpt = get_the_excerpt();
|
||||
if (empty($excerpt)) {
|
||||
$excerpt = wp_trim_words(get_the_content(), $length, '...');
|
||||
} else {
|
||||
$excerpt = wp_trim_words($excerpt, $length, '...');
|
||||
}
|
||||
|
||||
return sprintf('<p class="card-text">%s</p>', esc_html($excerpt));
|
||||
}
|
||||
|
||||
private function buildPaginationHTML(\WP_Query $query, array $options): string
|
||||
{
|
||||
$id = $options['id'] ?? '';
|
||||
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
|
||||
$currentPage = max(1, (int) get_query_var($queryVar, 1));
|
||||
$totalPages = $query->max_num_pages;
|
||||
|
||||
$html = '<nav class="pagination-wrapper mt-4"><ul class="pagination justify-content-center">';
|
||||
|
||||
for ($i = 1; $i <= $totalPages; $i++) {
|
||||
$activeClass = ($i === $currentPage) ? ' active' : '';
|
||||
$url = add_query_arg($queryVar, $i);
|
||||
$html .= sprintf(
|
||||
'<li class="page-item%s"><a class="page-link" href="%s">%d</a></li>',
|
||||
$activeClass,
|
||||
esc_url($url),
|
||||
$i
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</ul></nav>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return $value === 'true' || $value === '1' || $value === 1;
|
||||
}
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Domain\Contracts\FormBuilderInterface;
|
||||
|
||||
/**
|
||||
* TopNotificationBarFormBuilder - Construye formulario de configuración
|
||||
*
|
||||
* RESPONSABILIDAD: Generar formulario HTML del admin para Top Notification Bar
|
||||
*
|
||||
* CARACTERÍSTICAS:
|
||||
* - 3 secciones: Visibilidad, Contenido, Estilos
|
||||
* - 19 campos configurables
|
||||
* - Lógica condicional (data-conditional-field)
|
||||
* - WordPress Media Library integration
|
||||
* - Vista previa en tiempo real
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Ui
|
||||
*/
|
||||
final class TopNotificationBarFormBuilder implements FormBuilderInterface
|
||||
{
|
||||
public function build(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
$componentId = $component->getName();
|
||||
|
||||
$html = '<div class="roi-form-builder roi-top-notification-bar-form">';
|
||||
|
||||
// Sección de Visibilidad
|
||||
$html .= $this->buildVisibilitySection($data, $componentId);
|
||||
|
||||
// Sección de Contenido
|
||||
$html .= $this->buildContentSection($data, $componentId);
|
||||
|
||||
// Sección de Estilos
|
||||
$html .= $this->buildStylesSection($data, $componentId);
|
||||
|
||||
// Vista previa
|
||||
$html .= $this->buildPreviewSection($data);
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
// Agregar scripts de formulario
|
||||
$html .= $this->buildFormScripts($componentId);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildVisibilitySection(array $data, string $componentId): string
|
||||
{
|
||||
$html = '<div class="roi-form-section" data-section="visibility">';
|
||||
$html .= '<h3 class="roi-form-section-title">Visibilidad</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
|
||||
// Is Enabled
|
||||
$isEnabled = $data['visibility']['is_enabled'] ?? true;
|
||||
$html .= $this->buildToggle(
|
||||
'is_enabled',
|
||||
'Mostrar barra de notificación',
|
||||
$isEnabled,
|
||||
$componentId,
|
||||
'Activa o desactiva la barra de notificación superior'
|
||||
);
|
||||
|
||||
// Show On Pages
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||
$html .= $this->buildSelect(
|
||||
'show_on_pages',
|
||||
'Mostrar en',
|
||||
$showOn,
|
||||
[
|
||||
'all' => 'Todas las páginas',
|
||||
'home' => 'Solo página de inicio',
|
||||
'posts' => 'Solo posts individuales',
|
||||
'pages' => 'Solo páginas',
|
||||
'custom' => 'Páginas específicas'
|
||||
],
|
||||
$componentId,
|
||||
'Define en qué páginas se mostrará la barra'
|
||||
);
|
||||
|
||||
// Custom Page IDs
|
||||
$customPageIds = $data['visibility']['custom_page_ids'] ?? '';
|
||||
$html .= $this->buildTextField(
|
||||
'custom_page_ids',
|
||||
'IDs de páginas específicas',
|
||||
$customPageIds,
|
||||
$componentId,
|
||||
'IDs de páginas separados por comas',
|
||||
'Ej: 1,5,10',
|
||||
['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']
|
||||
);
|
||||
|
||||
// Hide On Mobile
|
||||
$hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false;
|
||||
$html .= $this->buildToggle(
|
||||
'hide_on_mobile',
|
||||
'Ocultar en dispositivos móviles',
|
||||
$hideOnMobile,
|
||||
$componentId,
|
||||
'Oculta la barra en pantallas menores a 768px'
|
||||
);
|
||||
|
||||
// Is Dismissible
|
||||
$isDismissible = $data['visibility']['is_dismissible'] ?? false;
|
||||
$html .= $this->buildToggle(
|
||||
'is_dismissible',
|
||||
'Permitir cerrar',
|
||||
$isDismissible,
|
||||
$componentId,
|
||||
'Agrega botón X para que el usuario pueda cerrar la barra'
|
||||
);
|
||||
|
||||
// Dismissible Cookie Days
|
||||
$cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7;
|
||||
$html .= $this->buildNumberField(
|
||||
'dismissible_cookie_days',
|
||||
'Días antes de volver a mostrar',
|
||||
$cookieDays,
|
||||
$componentId,
|
||||
'Días que permanece oculta después de cerrarla',
|
||||
1,
|
||||
365,
|
||||
['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildContentSection(array $data, string $componentId): string
|
||||
{
|
||||
$html = '<div class="roi-form-section" data-section="content">';
|
||||
$html .= '<h3 class="roi-form-section-title">Contenido</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
|
||||
// Icon Type
|
||||
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
|
||||
$html .= $this->buildSelect(
|
||||
'icon_type',
|
||||
'Tipo de ícono',
|
||||
$iconType,
|
||||
[
|
||||
'bootstrap' => 'Bootstrap Icons',
|
||||
'custom' => 'Imagen personalizada',
|
||||
'none' => 'Sin ícono'
|
||||
],
|
||||
$componentId,
|
||||
'Selecciona el tipo de ícono a mostrar'
|
||||
);
|
||||
|
||||
// Bootstrap Icon
|
||||
$bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
|
||||
$html .= $this->buildTextField(
|
||||
'bootstrap_icon',
|
||||
'Clase de ícono Bootstrap',
|
||||
$bootstrapIcon,
|
||||
$componentId,
|
||||
'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)',
|
||||
'Ej: bi-megaphone-fill',
|
||||
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap']
|
||||
);
|
||||
|
||||
// Custom Icon URL
|
||||
$customIconUrl = $data['content']['custom_icon_url'] ?? '';
|
||||
$html .= $this->buildMediaField(
|
||||
'custom_icon_url',
|
||||
'Imagen personalizada',
|
||||
$customIconUrl,
|
||||
$componentId,
|
||||
'Sube una imagen personalizada (recomendado: PNG 24x24px)',
|
||||
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom']
|
||||
);
|
||||
|
||||
// Announcement Label
|
||||
$announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:';
|
||||
$html .= $this->buildTextField(
|
||||
'announcement_label',
|
||||
'Etiqueta del anuncio',
|
||||
$announcementLabel,
|
||||
$componentId,
|
||||
'Texto destacado en negrita antes del mensaje',
|
||||
'Ej: Nuevo:, Importante:, Aviso:'
|
||||
);
|
||||
|
||||
// Announcement Text
|
||||
$announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
|
||||
$html .= $this->buildTextArea(
|
||||
'announcement_text',
|
||||
'Texto del anuncio',
|
||||
$announcementText,
|
||||
$componentId,
|
||||
'Mensaje principal del anuncio (máximo 200 caracteres)',
|
||||
3
|
||||
);
|
||||
|
||||
// Link Enabled
|
||||
$linkEnabled = $data['content']['link_enabled'] ?? true;
|
||||
$html .= $this->buildToggle(
|
||||
'link_enabled',
|
||||
'Mostrar enlace',
|
||||
$linkEnabled,
|
||||
$componentId,
|
||||
'Activa o desactiva el enlace de acción'
|
||||
);
|
||||
|
||||
// Link Text
|
||||
$linkText = $data['content']['link_text'] ?? 'Ver Catálogo';
|
||||
$html .= $this->buildTextField(
|
||||
'link_text',
|
||||
'Texto del enlace',
|
||||
$linkText,
|
||||
$componentId,
|
||||
'Texto del enlace de acción',
|
||||
'',
|
||||
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
// Link URL
|
||||
$linkUrl = $data['content']['link_url'] ?? '#';
|
||||
$html .= $this->buildUrlField(
|
||||
'link_url',
|
||||
'URL del enlace',
|
||||
$linkUrl,
|
||||
$componentId,
|
||||
'URL de destino del enlace',
|
||||
'https://',
|
||||
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
// Link Target
|
||||
$linkTarget = $data['content']['link_target'] ?? '_self';
|
||||
$html .= $this->buildSelect(
|
||||
'link_target',
|
||||
'Abrir enlace en',
|
||||
$linkTarget,
|
||||
[
|
||||
'_self' => 'Misma ventana',
|
||||
'_blank' => 'Nueva ventana'
|
||||
],
|
||||
$componentId,
|
||||
'Define cómo se abrirá el enlace',
|
||||
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildStylesSection(array $data, string $componentId): string
|
||||
{
|
||||
$html = '<div class="roi-form-section" data-section="styles">';
|
||||
$html .= '<h3 class="roi-form-section-title">Estilos</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
|
||||
// Background Color
|
||||
$bgColor = $data['styles']['background_color'] ?? '#FF8600';
|
||||
$html .= $this->buildColorField(
|
||||
'background_color',
|
||||
'Color de fondo',
|
||||
$bgColor,
|
||||
$componentId,
|
||||
'Color de fondo de la barra (por defecto: orange primary)'
|
||||
);
|
||||
|
||||
// Text Color
|
||||
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||
$html .= $this->buildColorField(
|
||||
'text_color',
|
||||
'Color del texto',
|
||||
$textColor,
|
||||
$componentId,
|
||||
'Color del texto del anuncio'
|
||||
);
|
||||
|
||||
// Link Color
|
||||
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
|
||||
$html .= $this->buildColorField(
|
||||
'link_color',
|
||||
'Color del enlace',
|
||||
$linkColor,
|
||||
$componentId,
|
||||
'Color del enlace de acción'
|
||||
);
|
||||
|
||||
// Font Size
|
||||
$fontSize = $data['styles']['font_size'] ?? 'small';
|
||||
$html .= $this->buildSelect(
|
||||
'font_size',
|
||||
'Tamaño de fuente',
|
||||
$fontSize,
|
||||
[
|
||||
'extra-small' => 'Muy pequeño (0.75rem)',
|
||||
'small' => 'Pequeño (0.875rem)',
|
||||
'normal' => 'Normal (1rem)',
|
||||
'large' => 'Grande (1.125rem)'
|
||||
],
|
||||
$componentId,
|
||||
'Tamaño del texto del anuncio'
|
||||
);
|
||||
|
||||
// Padding Vertical
|
||||
$padding = $data['styles']['padding_vertical'] ?? 'normal';
|
||||
$html .= $this->buildSelect(
|
||||
'padding_vertical',
|
||||
'Padding vertical',
|
||||
$padding,
|
||||
[
|
||||
'compact' => 'Compacto (0.5rem)',
|
||||
'normal' => 'Normal (0.75rem)',
|
||||
'spacious' => 'Espacioso (1rem)'
|
||||
],
|
||||
$componentId,
|
||||
'Espaciado vertical interno de la barra'
|
||||
);
|
||||
|
||||
// Text Alignment
|
||||
$alignment = $data['styles']['text_alignment'] ?? 'center';
|
||||
$html .= $this->buildSelect(
|
||||
'text_alignment',
|
||||
'Alineación del texto',
|
||||
$alignment,
|
||||
[
|
||||
'left' => 'Izquierda',
|
||||
'center' => 'Centro',
|
||||
'right' => 'Derecha'
|
||||
],
|
||||
$componentId,
|
||||
'Alineación del contenido de la barra'
|
||||
);
|
||||
|
||||
// Animation Enabled
|
||||
$animationEnabled = $data['styles']['animation_enabled'] ?? false;
|
||||
$html .= $this->buildToggle(
|
||||
'animation_enabled',
|
||||
'Activar animación',
|
||||
$animationEnabled,
|
||||
$componentId,
|
||||
'Activa animación de entrada al cargar la página'
|
||||
);
|
||||
|
||||
// Animation Type
|
||||
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
|
||||
$html .= $this->buildSelect(
|
||||
'animation_type',
|
||||
'Tipo de animación',
|
||||
$animationType,
|
||||
[
|
||||
'slide-down' => 'Deslizar desde arriba',
|
||||
'fade-in' => 'Aparecer gradualmente'
|
||||
],
|
||||
$componentId,
|
||||
'Tipo de animación de entrada',
|
||||
['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildPreviewSection(array $data): string
|
||||
{
|
||||
$html = '<div class="roi-form-section roi-preview-section">';
|
||||
$html .= '<h3 class="roi-form-section-title">Vista Previa</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
$html .= '<div id="roi-component-preview" class="border rounded p-3 bg-light">';
|
||||
$html .= '<p class="text-muted">La vista previa se actualizará automáticamente al modificar los campos.</p>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
$checked = $value ? 'checked' : '';
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-toggle mb-3">';
|
||||
$html .= '<div class="form-check form-switch">';
|
||||
$html .= sprintf(
|
||||
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s]" value="1" %s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
$checked
|
||||
);
|
||||
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
$html .= '</div>';
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-text mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" placeholder="%s"%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value),
|
||||
esc_attr($placeholder),
|
||||
$attrString
|
||||
);
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-textarea mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<textarea class="form-control" id="%s" name="roi_component[%s][%s]" rows="%d"%s>%s</textarea>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
$rows,
|
||||
$attrString,
|
||||
esc_textarea($value)
|
||||
);
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-select mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<select class="form-select" id="%s" name="roi_component[%s][%s]"%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
$attrString
|
||||
);
|
||||
|
||||
foreach ($options as $optValue => $optLabel) {
|
||||
$selected = ($value === $optValue) ? 'selected' : '';
|
||||
$html .= sprintf(
|
||||
'<option value="%s" %s>%s</option>',
|
||||
esc_attr($optValue),
|
||||
$selected,
|
||||
esc_html($optLabel)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</select>';
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-number mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrs['type'] = 'number';
|
||||
if ($min !== null) {
|
||||
$attrs['min'] = $min;
|
||||
}
|
||||
if ($max !== null) {
|
||||
$attrs['max'] = $max;
|
||||
}
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<input class="form-control" id="%s" name="roi_component[%s][%s]" value="%s"%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value),
|
||||
$attrString
|
||||
);
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
|
||||
{
|
||||
$attrs['type'] = 'url';
|
||||
return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs);
|
||||
}
|
||||
|
||||
private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-color mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
$html .= '<div class="input-group">';
|
||||
$html .= sprintf(
|
||||
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s]" value="%s">',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= sprintf(
|
||||
'<input type="text" class="form-control" value="%s" readonly>',
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= '</div>';
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-media mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
$html .= '<div class="input-group">';
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" readonly%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value),
|
||||
$attrString
|
||||
);
|
||||
$html .= sprintf(
|
||||
'<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>',
|
||||
esc_attr($fieldId)
|
||||
);
|
||||
$html .= '</div>';
|
||||
|
||||
if (!empty($value)) {
|
||||
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 100px; height: auto;"></div>', esc_url($value));
|
||||
}
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildAttributesString(array $attrs): string
|
||||
{
|
||||
$attrString = '';
|
||||
foreach ($attrs as $key => $value) {
|
||||
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
|
||||
}
|
||||
return $attrString;
|
||||
}
|
||||
|
||||
private function buildFormScripts(string $componentId): string
|
||||
{
|
||||
return <<<SCRIPT
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
$(document).ready(function() {
|
||||
// Conditional logic
|
||||
$('[data-conditional-field]').each(function() {
|
||||
const field = $(this);
|
||||
const targetFieldName = field.data('conditional-field');
|
||||
const targetValue = field.data('conditional-value');
|
||||
const targetField = $('[name*="[' + targetFieldName + ']"]');
|
||||
|
||||
function updateVisibility() {
|
||||
let currentValue;
|
||||
if (targetField.is(':checkbox')) {
|
||||
currentValue = targetField.is(':checked') ? 'true' : 'false';
|
||||
} else {
|
||||
currentValue = targetField.val();
|
||||
}
|
||||
|
||||
if (currentValue === targetValue) {
|
||||
field.closest('.roi-form-field').show();
|
||||
} else {
|
||||
field.closest('.roi-form-field').hide();
|
||||
}
|
||||
}
|
||||
|
||||
targetField.on('change', updateVisibility);
|
||||
updateVisibility();
|
||||
});
|
||||
|
||||
// Media upload
|
||||
$('.roi-media-upload-btn').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const button = $(this);
|
||||
const targetId = button.data('target');
|
||||
const targetField = $('#' + targetId);
|
||||
|
||||
const mediaUploader = wp.media({
|
||||
title: 'Seleccionar imagen',
|
||||
button: { text: 'Usar esta imagen' },
|
||||
multiple: false
|
||||
});
|
||||
|
||||
mediaUploader.on('select', function() {
|
||||
const attachment = mediaUploader.state().get('selection').first().toJSON();
|
||||
targetField.val(attachment.url);
|
||||
|
||||
const preview = targetField.closest('.roi-form-field-media').find('img');
|
||||
if (preview.length) {
|
||||
preview.attr('src', attachment.url);
|
||||
} else {
|
||||
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 100px; height: auto;"></div>');
|
||||
}
|
||||
});
|
||||
|
||||
mediaUploader.open();
|
||||
});
|
||||
|
||||
// Color picker sync
|
||||
$('.form-control-color').on('change', function() {
|
||||
$(this).next('input[type="text"]').val($(this).val());
|
||||
});
|
||||
|
||||
// Auto-update preview
|
||||
$('.roi-form-field input, .roi-form-field select, .roi-form-field textarea').on('change keyup', function() {
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
function updatePreview() {
|
||||
// Aquí iría la lógica para actualizar la vista previa en tiempo real
|
||||
console.log('Preview updated');
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
SCRIPT;
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'top-notification-bar';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Wordpress;
|
||||
|
||||
use ROITheme\Shared\Infrastructure\Di\DIContainer;
|
||||
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridRequest;
|
||||
|
||||
/**
|
||||
* Registra el shortcode [roi_post_grid] en WordPress
|
||||
*
|
||||
* RESPONSABILIDAD:
|
||||
* - Registrar shortcode via add_shortcode
|
||||
* - Sanitizar atributos del shortcode
|
||||
* - Delegar ejecucion a RenderPostGridUseCase
|
||||
*
|
||||
* NO RESPONSABLE DE:
|
||||
* - Logica de negocio
|
||||
* - Construccion de queries
|
||||
* - Generacion de HTML/CSS
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Wordpress
|
||||
*/
|
||||
final class PostGridShortcodeRegistrar
|
||||
{
|
||||
private const SHORTCODE_TAG = 'roi_post_grid';
|
||||
|
||||
/**
|
||||
* Registra el shortcode en WordPress
|
||||
*/
|
||||
public static function register(): void
|
||||
{
|
||||
add_shortcode(self::SHORTCODE_TAG, [new self(), 'handleShortcode']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback del shortcode
|
||||
*
|
||||
* @param array|string $atts Atributos del shortcode
|
||||
* @return string HTML del grid
|
||||
*/
|
||||
public function handleShortcode($atts): string
|
||||
{
|
||||
$atts = $this->sanitizeAttributes($atts);
|
||||
|
||||
// Obtener paged desde query var si existe
|
||||
$id = $atts['id'] ?? '';
|
||||
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
|
||||
$atts['paged'] = max(1, (int) get_query_var($queryVar, 1));
|
||||
|
||||
// Crear request DTO
|
||||
$request = RenderPostGridRequest::fromArray($atts);
|
||||
|
||||
// Obtener UseCase desde DIContainer
|
||||
$container = DIContainer::getInstance();
|
||||
$useCase = $container->getRenderPostGridUseCase();
|
||||
|
||||
return $useCase->execute($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiza atributos del shortcode
|
||||
*
|
||||
* @param array|string $atts
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeAttributes($atts): array
|
||||
{
|
||||
$atts = shortcode_atts([
|
||||
'category' => '',
|
||||
'exclude_category' => '',
|
||||
'tag' => '',
|
||||
'author' => '',
|
||||
'posts_per_page' => '9',
|
||||
'columns' => '3',
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'show_pagination' => 'false',
|
||||
'offset' => '0',
|
||||
'exclude_posts' => '',
|
||||
'show_thumbnail' => 'true',
|
||||
'show_excerpt' => 'true',
|
||||
'show_meta' => 'true',
|
||||
'show_categories' => 'true',
|
||||
'excerpt_length' => '20',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
], $atts, self::SHORTCODE_TAG);
|
||||
|
||||
// Sanitizar cada valor
|
||||
return array_map(function ($value) {
|
||||
return is_string($value) ? sanitize_text_field($value) : $value;
|
||||
}, $atts);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Part: CTA Box Sidebar
|
||||
*
|
||||
* Caja de llamada a la acción naranja en el sidebar
|
||||
* Abre el modal de contacto al hacer clic
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
?>
|
||||
|
||||
<!-- DEBUG: CTA Box Template Loaded -->
|
||||
<!-- CTA Box Sidebar -->
|
||||
<div class="cta-box-sidebar">
|
||||
<h5 class="cta-box-title">¿Listo para potenciar tus proyectos?</h5>
|
||||
<p class="cta-box-text">Accede a nuestra biblioteca completa de APUs y herramientas profesionales.</p>
|
||||
<button class="btn btn-cta-box w-100" data-bs-toggle="modal" data-bs-target="#contactModal">
|
||||
Solicitar Información
|
||||
</button>
|
||||
</div>
|
||||
<!-- DEBUG: CTA Box Template End -->
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Part: Table of Contents (TOC)
|
||||
*
|
||||
* Genera automáticamente TOC desde los H2 del post
|
||||
* HTML exacto del template original
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Solo mostrar TOC si estamos en single post
|
||||
if (!is_single()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtener el contenido del post actual
|
||||
global $post;
|
||||
$post_content = $post->post_content;
|
||||
|
||||
// Aplicar filtros de WordPress al contenido
|
||||
$post_content = apply_filters('the_content', $post_content);
|
||||
|
||||
// Buscar todos los H2 con ID en el contenido
|
||||
preg_match_all('/<h2[^>]*id=["\']([^"\']*)["\'][^>]*>(.*?)<\/h2>/i', $post_content, $matches);
|
||||
|
||||
// Si no hay H2 con ID, no mostrar TOC
|
||||
if (empty($matches[1])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generar el TOC con el HTML del template
|
||||
?>
|
||||
<div class="toc-container">
|
||||
<h4>Tabla de Contenido</h4>
|
||||
<ol class="list-unstyled toc-list">
|
||||
<?php foreach ($matches[1] as $index => $id) : ?>
|
||||
<?php $title = strip_tags($matches[2][$index]); ?>
|
||||
<li><a href="#<?php echo esc_attr($id); ?>"><?php echo esc_html($title); ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CTA Box Sidebar Template
|
||||
*
|
||||
* Aparece debajo del TOC en single posts
|
||||
*
|
||||
* @package ROI_Theme
|
||||
*/
|
||||
|
||||
if (!is_single()) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="cta-box-sidebar mt-3">
|
||||
<h5 class="cta-box-title">¿Listo para potenciar tus proyectos?</h5>
|
||||
<p class="cta-box-text">Accede a nuestra biblioteca completa de APUs y herramientas profesionales.</p>
|
||||
<button class="btn btn-cta-box w-100" data-bs-toggle="modal" data-bs-target="#contactModal">
|
||||
<i class="bi bi-calendar-check me-2"></i>Solicitar Demo
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Modal de Contacto - Bootstrap 5
|
||||
*
|
||||
* Modal activado por botón "Let's Talk" y CTA Box Sidebar
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
?>
|
||||
|
||||
<!-- Contact Modal -->
|
||||
<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="contactModalLabel">
|
||||
<i class="bi bi-envelope-fill me-2" style="color: #FF8600;"></i>
|
||||
<?php esc_html_e( '¿Tienes alguna pregunta?', 'roi-theme' ); ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php esc_attr_e( 'Cerrar', 'roi-theme' ); ?>"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-4">
|
||||
<?php esc_html_e( 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.', 'roi-theme' ); ?>
|
||||
</p>
|
||||
|
||||
<form id="modalContactForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="modalFullName" class="form-label"><?php esc_html_e( 'Nombre completo', 'roi-theme' ); ?> *</label>
|
||||
<input type="text" class="form-control" id="modalFullName" name="fullName" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="modalCompany" class="form-label"><?php esc_html_e( 'Empresa', 'roi-theme' ); ?></label>
|
||||
<input type="text" class="form-control" id="modalCompany" name="company">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="modalWhatsapp" class="form-label"><?php esc_html_e( 'WhatsApp', 'roi-theme' ); ?> *</label>
|
||||
<input type="tel" class="form-control" id="modalWhatsapp" name="whatsapp" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="modalEmail" class="form-label"><?php esc_html_e( 'Correo electrónico', 'roi-theme' ); ?> *</label>
|
||||
<input type="email" class="form-control" id="modalEmail" name="email" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="modalComments" class="form-label"><?php esc_html_e( '¿En qué podemos ayudarte?', 'roi-theme' ); ?></label>
|
||||
<textarea class="form-control" id="modalComments" name="comments" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-send-fill me-2"></i><?php esc_html_e( 'Enviar Mensaje', 'roi-theme' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
<div id="modalFormMessage" class="col-12 mt-2 alert" style="display: none;"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Top Notification Bar Component
|
||||
*
|
||||
* Barra de notificaciones superior del sitio
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 2.0.0
|
||||
*/
|
||||
?>
|
||||
|
||||
<div class="top-notification-bar">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-megaphone-fill me-2"></i>
|
||||
<span><strong>Nuevo:</strong> Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</span>
|
||||
<a href="#" class="ms-2 text-white text-decoration-underline">Ver Catálogo</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
281
archive.php
281
archive.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying archive pages
|
||||
*
|
||||
* This template displays date-based, category, tag, author, and post type
|
||||
* archives with a dynamic title, description, and post loop.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#archive
|
||||
*
|
||||
@@ -14,213 +15,99 @@
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
<!-- Archive Header - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Archive Header -->
|
||||
<header class="page-header">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
// Archive title
|
||||
the_archive_title( '<h1 class="page-title">', '</h1>' );
|
||||
|
||||
// Archive description
|
||||
$archive_description = get_the_archive_description();
|
||||
if ( ! empty( $archive_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Social Share - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('social-share');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-post');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Related Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('related-post');
|
||||
}
|
||||
?>
|
||||
|
||||
</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>
|
||||
<?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>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="post-content">
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
<!-- Post Header -->
|
||||
<header class="entry-header">
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- 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">→</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">«</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">»</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’t find what you’re looking for. Perhaps searching can help.', 'roi-theme' ); ?>
|
||||
</p>
|
||||
</div><!-- .page-content -->
|
||||
</section><!-- .no-results -->
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
191
author.php
191
author.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying author archive pages
|
||||
*
|
||||
* This template displays posts from a specific author with author
|
||||
* bio and information at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#author
|
||||
*
|
||||
@@ -14,150 +15,78 @@
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
<!-- Archive Header - Componente dinamico (detecta autor automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Author Archive Header -->
|
||||
<header class="page-header author-header">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
// Get the author
|
||||
$author = get_queried_object();
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="author-info">
|
||||
<!-- Author Avatar -->
|
||||
<div class="author-avatar">
|
||||
</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
|
||||
echo get_avatar(
|
||||
$author->ID,
|
||||
120,
|
||||
'',
|
||||
sprintf(
|
||||
/* translators: %s: author name */
|
||||
esc_attr__( 'Avatar for %s', 'roi-theme' ),
|
||||
esc_html( $author->display_name )
|
||||
),
|
||||
array(
|
||||
'class' => 'author-avatar-img',
|
||||
)
|
||||
);
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Author Stats -->
|
||||
<div class="author-meta">
|
||||
<span class="author-posts-count">
|
||||
<?php
|
||||
$post_count = count_user_posts( $author->ID, 'post', true );
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $post_count, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $post_count ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header><!-- .page-header -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
<!-- Author Posts Loop -->
|
||||
<div class="archive-posts author-posts">
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<?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">«</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">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
188
category.php
188
category.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying category archive pages
|
||||
*
|
||||
* This template displays posts from a specific category with category
|
||||
* information and description at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#category
|
||||
*
|
||||
@@ -14,118 +15,99 @@
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
<!-- Archive Header - Componente dinamico (detecta categoria automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Category Archive Header -->
|
||||
<header class="page-header category-header">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
// Category title
|
||||
the_archive_title( '<h1 class="page-title category-title">', '</h1>' );
|
||||
|
||||
// Category description
|
||||
$category_description = category_description();
|
||||
if ( ! empty( $category_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description category-description">
|
||||
<?php echo wp_kses_post( wpautop( $category_description ) ); ?>
|
||||
|
||||
<!-- Social Share - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('social-share');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-post');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Related Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('related-post');
|
||||
}
|
||||
?>
|
||||
|
||||
</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>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Category metadata -->
|
||||
<?php
|
||||
$category = get_queried_object();
|
||||
if ( $category ) :
|
||||
?>
|
||||
<div class="category-meta">
|
||||
<span class="category-count">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $category->count, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $category->count ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header><!-- .page-header -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
<!-- Category Posts Loop -->
|
||||
<div class="archive-posts category-posts">
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<?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">«</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">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
164
date.php
164
date.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying date-based archive pages
|
||||
*
|
||||
* This template displays posts from a specific date (year, month, or day)
|
||||
* with the date information displayed at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#date
|
||||
*
|
||||
@@ -14,115 +15,78 @@
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
<!-- Archive Header - Componente dinamico (detecta fecha automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Date Archive Header -->
|
||||
<header class="page-header date-header">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
// Date archive title
|
||||
the_archive_title( '<h1 class="page-title date-title">', '</h1>' );
|
||||
|
||||
// Date archive description
|
||||
$date_description = get_the_archive_description();
|
||||
if ( ! empty( $date_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description date-description">
|
||||
<?php echo wp_kses_post( wpautop( $date_description ) ); ?>
|
||||
|
||||
</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>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Date metadata -->
|
||||
<div class="date-meta">
|
||||
<span class="posts-count">
|
||||
<?php
|
||||
global $wp_query;
|
||||
$found_posts = $wp_query->found_posts;
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $found_posts, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $found_posts ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
</header><!-- .page-header -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
<!-- Date Posts Loop -->
|
||||
<div class="archive-posts date-posts">
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<?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">«</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">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
<?php
|
||||
|
||||
// =============================================================================
|
||||
// ROI THEME DEBUG MODE
|
||||
// =============================================================================
|
||||
// Para activar el modo debug, agregar en wp-config.php:
|
||||
// define('ROI_DEBUG', true);
|
||||
//
|
||||
// IMPORTANTE: Mantener desactivado en producción para evitar logs de GB.
|
||||
// =============================================================================
|
||||
if (!defined('ROI_DEBUG')) {
|
||||
define('ROI_DEBUG', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log de debug condicional para ROI Theme.
|
||||
* Solo escribe al log si ROI_DEBUG está activado.
|
||||
*/
|
||||
function roi_debug_log(string $message): void {
|
||||
if (ROI_DEBUG) {
|
||||
error_log($message);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUTOLOADER PARA COMPONENTES
|
||||
// =============================================================================
|
||||
@@ -181,7 +203,7 @@ function roi_render_component(string $componentName): string {
|
||||
global $wpdb;
|
||||
|
||||
// DEBUG: Trace component rendering
|
||||
error_log("ROI Theme DEBUG: roi_render_component called with: {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: roi_render_component called with: {$componentName}");
|
||||
|
||||
try {
|
||||
// Obtener datos del componente desde BD normalizada
|
||||
@@ -207,8 +229,17 @@ function roi_render_component(string $componentName): string {
|
||||
// Decodificar valor
|
||||
$value = $row->attribute_value;
|
||||
|
||||
// Convertir booleanos almacenados como '1' o '0'
|
||||
if ($value === '1' || $value === '0') {
|
||||
// Solo convertir a booleano campos que realmente son booleanos
|
||||
// Los grupos 'visibility', '_page_visibility' y campos que empiezan con 'show_', 'is_', 'enable'
|
||||
$isBooleanField = (
|
||||
$row->group_name === 'visibility' ||
|
||||
$row->group_name === '_page_visibility' ||
|
||||
str_starts_with($row->attribute_name, 'show_') ||
|
||||
str_starts_with($row->attribute_name, 'is_') ||
|
||||
str_starts_with($row->attribute_name, 'enable')
|
||||
);
|
||||
|
||||
if ($isBooleanField && ($value === '1' || $value === '0')) {
|
||||
$value = ($value === '1');
|
||||
} else {
|
||||
// Intentar decodificar JSON
|
||||
@@ -288,9 +319,9 @@ function roi_render_component(string $componentName): string {
|
||||
$renderer = new \ROITheme\Public\Navbar\Infrastructure\Ui\NavbarRenderer($cssGenerator);
|
||||
break;
|
||||
case 'hero':
|
||||
error_log("ROI Theme DEBUG: Creating HeroRenderer");
|
||||
roi_debug_log("ROI Theme DEBUG: Creating HeroRenderer");
|
||||
$renderer = new \ROITheme\Public\Hero\Infrastructure\Ui\HeroRenderer($cssGenerator);
|
||||
error_log("ROI Theme DEBUG: HeroRenderer created successfully");
|
||||
roi_debug_log("ROI Theme DEBUG: HeroRenderer created successfully");
|
||||
break;
|
||||
|
||||
// Componentes sin soporte de CSS Crítico (below-the-fold)
|
||||
@@ -321,16 +352,22 @@ function roi_render_component(string $componentName): string {
|
||||
case 'footer':
|
||||
$renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator);
|
||||
break;
|
||||
case 'archive-header':
|
||||
$renderer = new \ROITheme\Public\ArchiveHeader\Infrastructure\Ui\ArchiveHeaderRenderer($cssGenerator);
|
||||
break;
|
||||
case 'post-grid':
|
||||
$renderer = new \ROITheme\Public\PostGrid\Infrastructure\Ui\PostGridRenderer($cssGenerator);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$renderer) {
|
||||
error_log("ROI Theme DEBUG: No renderer for {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: No renderer for {$componentName}");
|
||||
return '';
|
||||
}
|
||||
|
||||
error_log("ROI Theme DEBUG: Calling render() for {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: Calling render() for {$componentName}");
|
||||
$output = $renderer->render($component);
|
||||
error_log("ROI Theme DEBUG: render() returned " . strlen($output) . " chars for {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: render() returned " . strlen($output) . " chars for {$componentName}");
|
||||
return $output;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -547,3 +584,22 @@ function roi_get_adsense_search_config(): array {
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// POST GRID SHORTCODE [roi_post_grid]
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Registra el shortcode [roi_post_grid] para mostrar grids de posts
|
||||
* en cualquier pagina o entrada.
|
||||
*
|
||||
* USO:
|
||||
* [roi_post_grid]
|
||||
* [roi_post_grid category="precios-unitarios" posts_per_page="6"]
|
||||
* [roi_post_grid id="grid-1" category="cursos" show_pagination="true"]
|
||||
*
|
||||
* @see Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar
|
||||
*/
|
||||
add_action('init', function() {
|
||||
\ROITheme\Shared\Infrastructure\Wordpress\PostGridShortcodeRegistrar::register();
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ require_once get_template_directory() . '/Inc/featured-image.php';
|
||||
require_once get_template_directory() . '/Inc/category-badge.php';
|
||||
require_once get_template_directory() . '/Inc/adsense-delay.php';
|
||||
require_once get_template_directory() . '/Inc/adsense-placement.php';
|
||||
require_once get_template_directory() . '/Inc/related-posts.php';
|
||||
// ELIMINADO: Inc/related-posts.php (Plan 101 - usa RelatedPostRenderer)
|
||||
// ELIMINADO: Inc/toc.php (FASE 6 - Clean Architecture: usa TableOfContentsRenderer)
|
||||
require_once get_template_directory() . '/Inc/apu-tables.php';
|
||||
require_once get_template_directory() . '/Inc/search-disable.php';
|
||||
@@ -174,6 +174,12 @@ try {
|
||||
);
|
||||
$youtubeFacadeHooksRegistrar->register();
|
||||
|
||||
// === CACHE-FIRST ARCHITECTURE (Plan 1000.01) ===
|
||||
// Hook para plugins externos que necesitan evaluar acceso antes de servir página
|
||||
// @see openspec/specs/cache-first-architecture/spec.md
|
||||
$cacheFirstHooksRegistrar = new \ROITheme\Shared\Infrastructure\Hooks\CacheFirstHooksRegistrar();
|
||||
$cacheFirstHooksRegistrar->register();
|
||||
|
||||
// Log en modo debug
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('ROI Theme: Admin Panel initialized successfully');
|
||||
|
||||
155
home.php
155
home.php
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* The template for displaying the blog posts index
|
||||
*
|
||||
* This template is used when the blog posts page is different from the front page.
|
||||
* It displays a listing of recent blog posts with pagination.
|
||||
* Set in WordPress Settings > Reading > "Posts page".
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#home
|
||||
*
|
||||
@@ -15,109 +15,78 @@
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
<!-- Archive Header - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Blog Header -->
|
||||
<header class="page-header">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
// Display blog page title
|
||||
if ( is_home() && ! is_front_page() && get_option( 'page_for_posts' ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
<h1 class="page-title">
|
||||
<?php echo esc_html( get_the_title( get_option( 'page_for_posts' ) ) ); ?>
|
||||
</h1>
|
||||
|
||||
</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
|
||||
// Display blog page description if available
|
||||
$blog_page = get_post( get_option( 'page_for_posts' ) );
|
||||
if ( $blog_page && ! empty( $blog_page->post_content ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
<div class="page-description">
|
||||
<?php echo wp_kses_post( wpautop( $blog_page->post_excerpt ) ); ?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?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 class="blog-posts">
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
</main><!-- #main-content -->
|
||||
|
||||
/**
|
||||
* Include the Post-Type-specific template for the content.
|
||||
* If you want to override this in a child theme, then include a file
|
||||
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', get_post_type() );
|
||||
|
||||
endwhile;
|
||||
?>
|
||||
|
||||
</div><!-- .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">«</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">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
154
openspec/specs/cache-first-architecture/spec.md
Normal file
154
openspec/specs/cache-first-architecture/spec.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Especificacion de Arquitectura Cache-First
|
||||
|
||||
## Purpose
|
||||
|
||||
Define la arquitectura cache-first para ROITheme que permite que plugins de cache (W3TC, Redis, etc.) funcionen correctamente mientras plugins externos pueden evaluar condiciones ANTES de servir paginas.
|
||||
|
||||
Esta arquitectura:
|
||||
- Permite que las paginas se sirvan desde cache siempre que sea posible
|
||||
- Provee hooks para que plugins externos (rate limiters, access control, etc.) evaluen condiciones
|
||||
- Desacopla el tema de plugins especificos de restriccion de acceso
|
||||
- Es portable: cualquier sitio con roi-theme tiene esta capacidad
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Hook de Pre-Evaluacion de Pagina
|
||||
|
||||
The system MUST provide a hook that fires BEFORE WordPress serves a page, allowing external plugins to evaluate conditions and potentially redirect.
|
||||
|
||||
#### Scenario: Plugin externo necesita evaluar acceso antes de servir pagina
|
||||
- **GIVEN** un plugin de control de acceso (rate limiter, membership, etc.)
|
||||
- **WHEN** un visitante solicita una pagina singular (post, page, CPT)
|
||||
- **THEN** el tema DEBE disparar `do_action('roi_theme_before_page_serve', $post_id)`
|
||||
- **AND** el hook DEBE ejecutarse en `template_redirect` con priority 0
|
||||
- **AND** si el plugin llama `wp_safe_redirect()` y `exit`, la pagina NO se sirve
|
||||
|
||||
#### Scenario: Ningn plugin enganchado al hook
|
||||
- **GIVEN** ningun plugin esta escuchando `roi_theme_before_page_serve`
|
||||
- **WHEN** un visitante solicita una pagina
|
||||
- **THEN** la pagina se sirve normalmente (con cache si disponible)
|
||||
- **AND** no hay impacto en rendimiento
|
||||
|
||||
#### Scenario: Hook solo dispara en paginas singulares
|
||||
- **GIVEN** el hook `roi_theme_before_page_serve`
|
||||
- **WHEN** la solicitud es para archivo, home, search, feed, o admin
|
||||
- **THEN** el hook NO DEBE dispararse
|
||||
- **AND** la pagina se sirve sin evaluacion adicional
|
||||
|
||||
#### Scenario: Hook no dispara para usuarios logueados
|
||||
- **GIVEN** un usuario autenticado (logged in)
|
||||
- **WHEN** solicita cualquier pagina
|
||||
- **THEN** el hook NO DEBE dispararse
|
||||
- **AND** la pagina se sirve directamente sin evaluacion
|
||||
- **BECAUSE** WordPress no cachea paginas para usuarios logueados (cookies de sesion)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Contexto Rico para Plugins Externos
|
||||
|
||||
The system MUST provide sufficient context for external plugins to make access decisions.
|
||||
|
||||
#### Scenario: Plugin necesita informacion del post
|
||||
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
|
||||
- **WHEN** el hook se dispara
|
||||
- **THEN** el plugin recibe `$post_id` como parametro
|
||||
- **AND** `get_queried_object()` retorna el objeto WP_Post completo
|
||||
- **AND** `is_singular()`, `is_single()`, `is_page()` funcionan correctamente
|
||||
|
||||
#### Scenario: Plugin necesita informacion del visitante
|
||||
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
|
||||
- **WHEN** el hook se dispara
|
||||
- **THEN** `is_user_logged_in()` esta disponible
|
||||
- **AND** `$_SERVER['REMOTE_ADDR']` esta disponible
|
||||
- **AND** headers HTTP estan disponibles via `$_SERVER`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: No Bloquear Cache
|
||||
|
||||
The system MUST NOT define DONOTCACHEPAGE or similar constants that prevent caching.
|
||||
|
||||
#### Scenario: Tema no interfiere con plugins de cache
|
||||
- **GIVEN** el tema roi-theme instalado
|
||||
- **WHEN** W3TC, WP Super Cache, o Redis Object Cache estan activos
|
||||
- **THEN** el tema NO DEBE definir `DONOTCACHEPAGE`
|
||||
- **AND** el tema NO DEBE definir `DONOTCACHEOBJECT`
|
||||
- **AND** el tema NO DEBE enviar headers `Cache-Control: no-cache`
|
||||
|
||||
#### Scenario: Plugin externo decide bloquear cache
|
||||
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
|
||||
- **WHEN** el plugin necesita bloquear cache para una pagina especifica
|
||||
- **THEN** es responsabilidad del PLUGIN definir `DONOTCACHEPAGE`
|
||||
- **AND** el tema NO participa en esa decision
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Ubicacion en Clean Architecture
|
||||
|
||||
The hook registration MUST follow ROITheme's Clean Architecture patterns.
|
||||
|
||||
#### Scenario: Hook registrado en Infrastructure
|
||||
- **GIVEN** el sistema de hooks del tema
|
||||
- **WHEN** el hook `roi_theme_before_page_serve` es registrado
|
||||
- **THEN** el registro DEBE estar en `Shared/Infrastructure/Hooks/`
|
||||
- **AND** DEBE seguir el patron de HooksRegistrar existente
|
||||
|
||||
#### Scenario: Servicio como punto de extension
|
||||
- **GIVEN** la arquitectura del tema
|
||||
- **WHEN** se necesita extender funcionalidad de pre-evaluacion
|
||||
- **THEN** DEBE existir una interfaz en `Shared/Domain/Contracts/`
|
||||
- **AND** la implementacion DEBE estar en `Shared/Infrastructure/Services/`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Hook Priority
|
||||
|
||||
```
|
||||
template_redirect priority order:
|
||||
├─ Priority 0: roi_theme_before_page_serve (tema dispara hook)
|
||||
│ └─ Plugins externos se enganchan aqui
|
||||
├─ Priority 1+: Otros plugins
|
||||
└─ Priority 10 (default): WordPress template loading
|
||||
```
|
||||
|
||||
### Ejemplo de Plugin Enganchado
|
||||
|
||||
```php
|
||||
// En un plugin externo (ej: ip-rate-limiter)
|
||||
add_action('roi_theme_before_page_serve', function(int $post_id) {
|
||||
// Evaluar condicion (ej: limite de IP)
|
||||
if ($this->is_limit_exceeded()) {
|
||||
wp_safe_redirect('/suscripcion-vip/?reason=limit', 302);
|
||||
exit;
|
||||
}
|
||||
// Si no hay problema, simplemente retornar
|
||||
// La pagina se servira (con cache si disponible)
|
||||
}, 10);
|
||||
```
|
||||
|
||||
### Archivos a Crear
|
||||
|
||||
```
|
||||
Shared/
|
||||
├── Domain/
|
||||
│ └── Contracts/
|
||||
│ └── PageServeHookInterface.php # Interface del hook
|
||||
└── Infrastructure/
|
||||
└── Hooks/
|
||||
└── CacheFirstHooksRegistrar.php # Registro del hook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. El hook `roi_theme_before_page_serve` se dispara en `template_redirect` priority 0
|
||||
2. Solo se dispara para `is_singular() === true`
|
||||
3. NO se dispara para usuarios logueados (`is_user_logged_in() === true`)
|
||||
4. Pasa `$post_id` como parametro al hook
|
||||
5. No define DONOTCACHEPAGE ni headers anti-cache
|
||||
6. Plugins externos pueden enganchar y hacer redirect/exit
|
||||
7. Si ningun plugin engancha, no hay impacto en rendimiento
|
||||
8. Sigue patrones de Clean Architecture del tema
|
||||
410
openspec/specs/post-grid-shortcode/spec.md
Normal file
410
openspec/specs/post-grid-shortcode/spec.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# Especificacion de Shortcode Post Grid
|
||||
|
||||
## Purpose
|
||||
|
||||
Crear un shortcode `[roi_post_grid]` que permita insertar grids de posts en cualquier pagina o entrada de WordPress, con filtros configurables por categoria, tag, autor y otras opciones. Sigue Clean Architecture delegando logica a Use Cases.
|
||||
|
||||
## Context
|
||||
|
||||
### Problema Actual
|
||||
El componente `post-grid` implementado en `templates-unificados` solo funciona en templates de archivo (home.php, archive.php, etc.) porque depende del loop principal de WordPress (`global $wp_query`).
|
||||
|
||||
### Solucion
|
||||
Crear un shortcode que:
|
||||
1. Siga Clean Architecture (ShortcodeRegistrar → UseCase → Repository)
|
||||
2. Reutilice estilos del componente `post-grid` existente
|
||||
3. Permita filtrar posts por categoria, tag, autor
|
||||
4. No dependa del loop principal de WordPress
|
||||
|
||||
### Relacion con templates-unificados
|
||||
- Este shortcode **complementa** (no reemplaza) el componente post-grid
|
||||
- El componente post-grid sigue funcionando en templates de archivo
|
||||
- El shortcode permite insertar grids en contenido arbitrario
|
||||
|
||||
### Nota sobre shortcodes legacy
|
||||
Los shortcodes existentes (`apu_table`, `apu_row`) estan en `Inc/apu-tables.php` (patron legacy). Este nuevo shortcode sigue el patron moderno de Clean Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Arquitectura del Shortcode
|
||||
|
||||
The shortcode MUST follow Clean Architecture patterns.
|
||||
|
||||
#### Scenario: Ubicacion del ShortcodeRegistrar
|
||||
- **WHEN** se implementa el shortcode
|
||||
- **THEN** DEBE estar en `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Wordpress`
|
||||
- **AND** DEBE registrar el shortcode via add_shortcode
|
||||
|
||||
#### Scenario: Delegacion a Use Case
|
||||
- **WHEN** el shortcode se ejecuta
|
||||
- **THEN** PostGridShortcodeRegistrar DEBE delegar a RenderPostGridUseCase
|
||||
- **AND** NO DEBE contener logica de negocio
|
||||
- **AND** solo sanitiza atributos y pasa al Use Case
|
||||
|
||||
#### Scenario: Ubicacion del Use Case
|
||||
- **WHEN** se implementa la logica del shortcode
|
||||
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Application\UseCases\RenderPostGrid`
|
||||
- **AND** DEBE orquestar Query, Renderer y Settings
|
||||
|
||||
#### Scenario: Ubicacion del QueryBuilder
|
||||
- **WHEN** se construye el WP_Query
|
||||
- **THEN** DEBE estar en `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Query`
|
||||
- **AND** DEBE recibir parametros y retornar WP_Query
|
||||
|
||||
#### Scenario: Ubicacion del ShortcodeRenderer
|
||||
- **WHEN** se genera el HTML del grid
|
||||
- **THEN** DEBE estar en `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Ui`
|
||||
- **AND** DEBE inyectar CSSGeneratorInterface
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Estructura de Clases
|
||||
|
||||
Each class MUST have single responsibility following SRP.
|
||||
|
||||
#### Scenario: Responsabilidad de PostGridShortcodeRegistrar
|
||||
- **WHEN** se define PostGridShortcodeRegistrar
|
||||
- **THEN** DEBE tener metodo estatico `register()` para add_shortcode
|
||||
- **AND** DEBE tener metodo `handleShortcode(array $atts): string`
|
||||
- **AND** handleShortcode DEBE sanitizar atributos
|
||||
- **AND** handleShortcode DEBE obtener UseCase del DIContainer
|
||||
- **AND** handleShortcode DEBE retornar resultado del UseCase
|
||||
- **AND** NO DEBE tener mas de 50 lineas
|
||||
|
||||
#### Scenario: Responsabilidad de RenderPostGridUseCase
|
||||
- **WHEN** se define RenderPostGridUseCase
|
||||
- **THEN** DEBE recibir RenderPostGridRequest como input
|
||||
- **AND** DEBE inyectar PostGridQueryBuilderInterface via constructor
|
||||
- **AND** DEBE inyectar PostGridShortcodeRendererInterface via constructor
|
||||
- **AND** DEBE inyectar ComponentSettingsRepositoryInterface via constructor
|
||||
- **AND** DEBE orquestar: obtener settings, construir query, renderizar
|
||||
- **AND** DEBE retornar string HTML
|
||||
- **AND** NO DEBE tener mas de 80 lineas
|
||||
|
||||
#### Scenario: Responsabilidad de PostGridQueryBuilder
|
||||
- **WHEN** se define PostGridQueryBuilder
|
||||
- **THEN** DEBE recibir parametros de filtro (category, tag, etc.)
|
||||
- **AND** DEBE construir array de argumentos WP_Query
|
||||
- **AND** DEBE retornar WP_Query configurado
|
||||
- **AND** NO DEBE generar HTML
|
||||
- **AND** NO DEBE tener mas de 100 lineas
|
||||
|
||||
#### Scenario: Responsabilidad de PostGridShortcodeRenderer
|
||||
- **WHEN** se define PostGridShortcodeRenderer
|
||||
- **THEN** DEBE recibir WP_Query y configuracion
|
||||
- **AND** DEBE inyectar CSSGeneratorInterface
|
||||
- **AND** DEBE generar HTML del grid
|
||||
- **AND** DEBE generar CSS inline
|
||||
- **AND** NO DEBE construir queries
|
||||
- **AND** NO DEBE tener mas de 150 lineas
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Interfaces en Domain/Application
|
||||
|
||||
Interfaces MUST be defined for dependency injection.
|
||||
|
||||
#### Scenario: Interface del QueryBuilder
|
||||
- **WHEN** se define la interface
|
||||
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
|
||||
- **AND** DEBE tener metodo `build(array $params): \WP_Query`
|
||||
|
||||
#### Scenario: Interface del Renderer
|
||||
- **WHEN** se define la interface
|
||||
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
|
||||
- **AND** DEBE tener metodo `render(\WP_Query $query, array $settings, array $options): string`
|
||||
|
||||
#### Scenario: Request DTO
|
||||
- **WHEN** se define el DTO de entrada
|
||||
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
|
||||
- **AND** DEBE ser readonly class con propiedades tipadas
|
||||
- **AND** DEBE contener todos los atributos del shortcode
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Atributos del Shortcode
|
||||
|
||||
The shortcode MUST accept configurable attributes.
|
||||
|
||||
#### Scenario: Atributo category
|
||||
- **WHEN** se usa `[roi_post_grid category="precios-unitarios"]`
|
||||
- **THEN** DEBE filtrar posts por slug de categoria
|
||||
- **AND** DEBE aceptar multiples categorias separadas por coma
|
||||
- **AND** ejemplo: `category="cat1,cat2,cat3"`
|
||||
|
||||
#### Scenario: Atributo exclude_category
|
||||
- **WHEN** se usa `[roi_post_grid exclude_category="noticias"]`
|
||||
- **THEN** DEBE excluir posts de esas categorias
|
||||
- **AND** DEBE aceptar multiples categorias separadas por coma
|
||||
|
||||
#### Scenario: Atributo tag
|
||||
- **WHEN** se usa `[roi_post_grid tag="concreto"]`
|
||||
- **THEN** DEBE filtrar posts por slug de tag
|
||||
- **AND** DEBE aceptar multiples tags separados por coma
|
||||
|
||||
#### Scenario: Atributo author
|
||||
- **WHEN** se usa `[roi_post_grid author="admin"]`
|
||||
- **THEN** DEBE filtrar posts por login de autor
|
||||
- **AND** DEBE aceptar ID numerico o login string
|
||||
|
||||
#### Scenario: Atributo posts_per_page
|
||||
- **WHEN** se usa `[roi_post_grid posts_per_page="6"]`
|
||||
- **THEN** DEBE limitar cantidad de posts mostrados
|
||||
- **AND** default DEBE ser 9
|
||||
- **AND** DEBE aceptar valores entre 1 y 50
|
||||
|
||||
#### Scenario: Atributo columns
|
||||
- **WHEN** se usa `[roi_post_grid columns="4"]`
|
||||
- **THEN** DEBE definir columnas en desktop
|
||||
- **AND** default DEBE ser 3
|
||||
- **AND** DEBE aceptar valores 1, 2, 3 o 4
|
||||
|
||||
#### Scenario: Atributo orderby
|
||||
- **WHEN** se usa `[roi_post_grid orderby="title"]`
|
||||
- **THEN** DEBE ordenar posts por ese campo
|
||||
- **AND** default DEBE ser "date"
|
||||
- **AND** opciones validas: date, title, modified, rand, comment_count
|
||||
|
||||
#### Scenario: Atributo order
|
||||
- **WHEN** se usa `[roi_post_grid order="ASC"]`
|
||||
- **THEN** DEBE definir direccion del orden
|
||||
- **AND** default DEBE ser "DESC"
|
||||
- **AND** opciones validas: ASC, DESC
|
||||
|
||||
#### Scenario: Atributo show_pagination
|
||||
- **WHEN** se usa `[roi_post_grid show_pagination="true"]`
|
||||
- **THEN** DEBE mostrar paginacion si hay mas posts
|
||||
- **AND** default DEBE ser false
|
||||
|
||||
#### Scenario: Atributo offset
|
||||
- **WHEN** se usa `[roi_post_grid offset="3"]`
|
||||
- **THEN** DEBE saltar los primeros N posts
|
||||
- **AND** default DEBE ser 0
|
||||
|
||||
#### Scenario: Atributo exclude_posts
|
||||
- **WHEN** se usa `[roi_post_grid exclude_posts="123,456"]`
|
||||
- **THEN** DEBE excluir posts por ID
|
||||
- **AND** DEBE aceptar IDs separados por coma
|
||||
|
||||
#### Scenario: Atributos de visualizacion
|
||||
- **WHEN** se usan atributos de visualizacion
|
||||
- **THEN** show_thumbnail default true
|
||||
- **AND** show_excerpt default true
|
||||
- **AND** show_meta default true
|
||||
- **AND** show_categories default true
|
||||
- **AND** excerpt_length default 20
|
||||
|
||||
#### Scenario: Atributo class
|
||||
- **WHEN** se usa `[roi_post_grid class="my-custom-grid"]`
|
||||
- **THEN** DEBE agregar clase CSS adicional al contenedor
|
||||
|
||||
#### Scenario: Atributo id para paginacion multiple
|
||||
- **WHEN** se usa `[roi_post_grid id="grid-1" show_pagination="true"]`
|
||||
- **THEN** DEBE usar query var unico `paged_grid-1`
|
||||
- **AND** permite multiples shortcodes paginados en misma pagina
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Obtencion de Settings
|
||||
|
||||
The shortcode MUST obtain styles from post-grid component settings.
|
||||
|
||||
#### Scenario: Lectura de configuracion
|
||||
- **WHEN** RenderPostGridUseCase se ejecuta
|
||||
- **THEN** DEBE usar ComponentSettingsRepositoryInterface
|
||||
- **AND** DEBE obtener settings de componente 'post-grid'
|
||||
- **AND** DEBE aplicar colores, spacing, visual_effects del componente
|
||||
|
||||
#### Scenario: Settings no encontrados
|
||||
- **WHEN** no existen settings de post-grid en BD
|
||||
- **THEN** DEBE usar valores default definidos en VisibilityDefaults
|
||||
- **AND** NO DEBE fallar con error
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Renderizado HTML
|
||||
|
||||
The shortcode MUST generate valid HTML with proper escaping.
|
||||
|
||||
#### Scenario: Estructura HTML del grid
|
||||
- **WHEN** el shortcode renderiza
|
||||
- **THEN** DEBE generar contenedor con clase `roi-post-grid-shortcode`
|
||||
- **AND** DEBE generar row con clase Bootstrap `row`
|
||||
- **AND** cada card DEBE tener columna responsive
|
||||
|
||||
#### Scenario: Clases responsive de columnas
|
||||
- **WHEN** columns es 3
|
||||
- **THEN** cada card DEBE tener `col-12 col-md-6 col-lg-4`
|
||||
- **AND** para columns=4: `col-12 col-md-6 col-lg-3`
|
||||
- **AND** para columns=2: `col-12 col-md-6`
|
||||
- **AND** para columns=1: `col-12`
|
||||
|
||||
#### Scenario: Sin resultados
|
||||
- **WHEN** el query no encuentra posts
|
||||
- **THEN** DEBE mostrar mensaje "No se encontraron publicaciones"
|
||||
- **AND** NO DEBE romper layout
|
||||
|
||||
#### Scenario: Escaping obligatorio
|
||||
- **WHEN** se genera HTML
|
||||
- **THEN** DEBE usar esc_html() para textos
|
||||
- **AND** DEBE usar esc_attr() para atributos
|
||||
- **AND** DEBE usar esc_url() para URLs
|
||||
- **AND** DEBE usar wp_kses_post() para excerpts
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CSS via CSSGenerator
|
||||
|
||||
The shortcode MUST use CSSGeneratorInterface for styles.
|
||||
|
||||
#### Scenario: Generacion de CSS
|
||||
- **WHEN** PostGridShortcodeRenderer genera CSS
|
||||
- **THEN** DEBE inyectar CSSGeneratorInterface
|
||||
- **AND** DEBE usar settings de post-grid desde BD
|
||||
- **AND** DEBE generar CSS inline en el shortcode
|
||||
|
||||
#### Scenario: Selector unico
|
||||
- **WHEN** se genera CSS
|
||||
- **THEN** DEBE usar selector `.roi-post-grid-shortcode`
|
||||
- **AND** si se especifica id, usar `.roi-post-grid-shortcode-{id}`
|
||||
- **AND** NO DEBE conflictuar con post-grid del template
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Registro del Shortcode
|
||||
|
||||
The shortcode MUST be registered in WordPress.
|
||||
|
||||
#### Scenario: Registro en bootstrap
|
||||
- **WHEN** WordPress carga el tema
|
||||
- **THEN** functions-addon.php DEBE llamar PostGridShortcodeRegistrar::register()
|
||||
- **AND** DEBE estar disponible en editor clasico y Gutenberg
|
||||
|
||||
#### Scenario: Metodo register estatico
|
||||
- **WHEN** se llama PostGridShortcodeRegistrar::register()
|
||||
- **THEN** DEBE ejecutar add_shortcode('roi_post_grid', ...)
|
||||
- **AND** DEBE usar DIContainer para obtener dependencias
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Fase 1: Interfaces y DTOs
|
||||
1. Crear `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
|
||||
2. Crear `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
|
||||
3. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
|
||||
|
||||
### Fase 2: Use Case
|
||||
1. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
|
||||
2. Implementar orquestacion de query, settings, render
|
||||
|
||||
### Fase 3: Infrastructure - Query
|
||||
1. Crear `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
|
||||
2. Implementar construccion de WP_Query con todos los filtros
|
||||
|
||||
### Fase 4: Infrastructure - Renderer
|
||||
1. Crear `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
|
||||
2. Implementar generacion HTML y CSS
|
||||
|
||||
### Fase 5: Infrastructure - Registrar
|
||||
1. Crear `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
|
||||
2. Implementar registro y sanitizacion de atributos
|
||||
|
||||
### Fase 6: Registro y DI
|
||||
1. Registrar en DIContainer las implementaciones
|
||||
2. Llamar register() en functions-addon.php
|
||||
|
||||
### Fase 7: Testing
|
||||
1. Probar en pagina estatica
|
||||
2. Verificar filtros por categoria, tag
|
||||
3. Verificar paginacion con id unico
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Shared/
|
||||
├── Domain/
|
||||
│ └── Contracts/
|
||||
│ ├── PostGridQueryBuilderInterface.php
|
||||
│ └── PostGridShortcodeRendererInterface.php
|
||||
├── Application/
|
||||
│ └── UseCases/
|
||||
│ └── RenderPostGrid/
|
||||
│ ├── RenderPostGridRequest.php
|
||||
│ └── RenderPostGridUseCase.php
|
||||
└── Infrastructure/
|
||||
├── Query/
|
||||
│ └── PostGridQueryBuilder.php
|
||||
├── Ui/
|
||||
│ └── PostGridShortcodeRenderer.php
|
||||
└── Wordpress/
|
||||
└── PostGridShortcodeRegistrar.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Ejemplo: Grid basico
|
||||
```
|
||||
[roi_post_grid]
|
||||
```
|
||||
|
||||
### Ejemplo: Posts de una categoria
|
||||
```
|
||||
[roi_post_grid category="precios-unitarios" posts_per_page="6"]
|
||||
```
|
||||
|
||||
### Ejemplo: Multiples shortcodes con paginacion
|
||||
```
|
||||
[roi_post_grid id="grid-cursos" category="cursos" show_pagination="true"]
|
||||
|
||||
[roi_post_grid id="grid-tutoriales" category="tutoriales" show_pagination="true"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Existentes (reutilizar)
|
||||
- `CSSGeneratorInterface` - Para generar CSS
|
||||
- `ComponentSettingsRepositoryInterface` - Para leer config de post-grid
|
||||
- `DIContainer` - Para inyeccion de dependencias
|
||||
- Bootstrap 5 grid system - Para layout responsive
|
||||
|
||||
### Nuevas (crear)
|
||||
- `PostGridQueryBuilderInterface` - Contrato para query builder
|
||||
- `PostGridShortcodeRendererInterface` - Contrato para renderer
|
||||
- `RenderPostGridRequest` - DTO de entrada
|
||||
- `RenderPostGridUseCase` - Orquestador
|
||||
- `PostGridQueryBuilder` - Implementacion query
|
||||
- `PostGridShortcodeRenderer` - Implementacion render
|
||||
- `PostGridShortcodeRegistrar` - Registro WordPress
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Shortcode renderiza sin atributos
|
||||
- [ ] Filtro por categoria funciona
|
||||
- [ ] Filtro por multiples categorias funciona
|
||||
- [ ] Exclusion de categoria funciona
|
||||
- [ ] Filtro por tag funciona
|
||||
- [ ] Columnas 1, 2, 3, 4 funcionan
|
||||
- [ ] Paginacion con id unico funciona
|
||||
- [ ] Multiples shortcodes paginados funcionan
|
||||
- [ ] CSS se aplica desde settings de post-grid
|
||||
- [ ] Mensaje "sin posts" aparece cuando corresponde
|
||||
- [ ] Escaping correcto en todo el HTML
|
||||
- [ ] Funciona en editor clasico
|
||||
- [ ] Funciona en Gutenberg
|
||||
- [ ] No hay errores PHP
|
||||
- [ ] Clases tienen menos de 150 lineas
|
||||
417
openspec/specs/templates-unificados/spec.md
Normal file
417
openspec/specs/templates-unificados/spec.md
Normal 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
|
||||
168
search.php
168
search.php
@@ -2,9 +2,15 @@
|
||||
/**
|
||||
* The template for displaying search results pages
|
||||
*
|
||||
* IMPORTANT: This theme has search functionality disabled.
|
||||
* All search attempts will be redirected to 404.
|
||||
* This template serves as a fallback and will display a 404 error.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* NOTA: Si se desea deshabilitar la busqueda, descomentar las lineas siguientes:
|
||||
* status_header( 404 );
|
||||
* nocache_headers();
|
||||
* include( get_404_template() );
|
||||
* exit;
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#search-result
|
||||
*
|
||||
@@ -12,117 +18,81 @@
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Force 404 status
|
||||
status_header( 404 );
|
||||
nocache_headers();
|
||||
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<section class="error-404 not-found search-disabled" aria-labelledby="search-error-title">
|
||||
<!-- Archive Header - Componente dinamico (detecta busqueda automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Error Header -->
|
||||
<header class="page-header">
|
||||
<h1 id="search-error-title" class="page-title">
|
||||
<?php esc_html_e( 'Search Unavailable', 'roi-theme' ); ?>
|
||||
</h1>
|
||||
</header><!-- .page-header -->
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Error Content -->
|
||||
<div class="page-content">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<p class="error-message">
|
||||
<?php esc_html_e( 'The search functionality is currently disabled on this website.', 'roi-theme' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
$categories = get_categories(
|
||||
array(
|
||||
'orderby' => 'count',
|
||||
'order' => 'DESC',
|
||||
'number' => 10,
|
||||
'hide_empty' => true,
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $categories ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
<div class="categories-section">
|
||||
<h3><?php esc_html_e( 'Browse by Category', 'roi-theme' ); ?></h3>
|
||||
<ul class="categories-list" role="list">
|
||||
<?php foreach ( $categories as $category ) : ?>
|
||||
<li>
|
||||
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>">
|
||||
<?php echo esc_html( $category->name ); ?>
|
||||
<span class="category-count">(<?php echo esc_html( $category->count ); ?>)</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
</div><!-- .<?php 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>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Recent Posts -->
|
||||
<?php
|
||||
$recent_posts = wp_get_recent_posts(
|
||||
array(
|
||||
'numberposts' => 10,
|
||||
'post_status' => 'publish',
|
||||
)
|
||||
);
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
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;
|
||||
?>
|
||||
</main><!-- #main-content -->
|
||||
|
||||
</div><!-- .error-actions -->
|
||||
|
||||
</div><!-- .page-content -->
|
||||
|
||||
</section><!-- .error-404 -->
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
167
tag.php
167
tag.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying tag archive pages
|
||||
*
|
||||
* This template displays posts associated with a specific tag,
|
||||
* with tag information displayed at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#tag
|
||||
*
|
||||
@@ -14,118 +15,78 @@
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
<!-- Archive Header - Componente dinamico (detecta tag automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Tag Archive Header -->
|
||||
<header class="page-header tag-header">
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
// Tag title
|
||||
the_archive_title( '<h1 class="page-title tag-title">', '</h1>' );
|
||||
|
||||
// Tag description
|
||||
$tag_description = tag_description();
|
||||
if ( ! empty( $tag_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description tag-description">
|
||||
<?php echo wp_kses_post( wpautop( $tag_description ) ); ?>
|
||||
|
||||
</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>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Tag metadata -->
|
||||
<?php
|
||||
$tag = get_queried_object();
|
||||
if ( $tag ) :
|
||||
?>
|
||||
<div class="tag-meta">
|
||||
<span class="tag-count">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $tag->count, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $tag->count ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header><!-- .page-header -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
<!-- Tag Posts Loop -->
|
||||
<div class="archive-posts tag-posts">
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<?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">«</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">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
27
test-shortcode.php
Normal file
27
test-shortcode.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: Test Post Grid Shortcode
|
||||
*
|
||||
* Página de prueba para el shortcode [roi_post_grid]
|
||||
*/
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main class="container py-5">
|
||||
<h1>Prueba de Shortcode [roi_post_grid]</h1>
|
||||
|
||||
<h2>Test 1: Grid básico (9 posts)</h2>
|
||||
<?php echo do_shortcode('[roi_post_grid]'); ?>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<h2>Test 2: 6 posts en 2 columnas</h2>
|
||||
<?php echo do_shortcode('[roi_post_grid posts_per_page="6" columns="2"]'); ?>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<h2>Test 3: 4 posts en 4 columnas sin meta</h2>
|
||||
<?php echo do_shortcode('[roi_post_grid posts_per_page="4" columns="4" show_meta="false"]'); ?>
|
||||
</main>
|
||||
|
||||
<?php get_footer(); ?>
|
||||
Reference in New Issue
Block a user