feat(theme): add [roi_post_grid] shortcode for static pages
- Create PostGridShortcodeRegistrar for WordPress shortcode registration - Implement RenderPostGridUseCase following Clean Architecture - Add PostGridQueryBuilder for custom WP_Query construction - Add PostGridShortcodeRenderer for HTML/CSS generation - Register shortcode in DIContainer with proper DI - Add shortcode usage guide in post-grid admin panel - Fix sidebar layout: add hide_for_logged_in check to wrapper visibility Shortcode attributes: category, tag, author, posts_per_page, columns, show_pagination, show_thumbnail, show_excerpt, show_meta, etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,14 +33,15 @@ final class PostGridFormBuilder
|
|||||||
|
|
||||||
// Columna izquierda
|
// Columna izquierda
|
||||||
$html .= '<div class="col-lg-6">';
|
$html .= '<div class="col-lg-6">';
|
||||||
|
$html .= $this->buildShortcodeGuide();
|
||||||
$html .= $this->buildVisibilityGroup($componentId);
|
$html .= $this->buildVisibilityGroup($componentId);
|
||||||
$html .= $this->buildContentGroup($componentId);
|
$html .= $this->buildContentGroup($componentId);
|
||||||
$html .= $this->buildLayoutGroup($componentId);
|
|
||||||
$html .= $this->buildMediaGroup($componentId);
|
$html .= $this->buildMediaGroup($componentId);
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
|
|
||||||
// Columna derecha
|
// Columna derecha
|
||||||
$html .= '<div class="col-lg-6">';
|
$html .= '<div class="col-lg-6">';
|
||||||
|
$html .= $this->buildLayoutGroup($componentId);
|
||||||
$html .= $this->buildTypographyGroup($componentId);
|
$html .= $this->buildTypographyGroup($componentId);
|
||||||
$html .= $this->buildColorsGroup($componentId);
|
$html .= $this->buildColorsGroup($componentId);
|
||||||
$html .= $this->buildSpacingGroup($componentId);
|
$html .= $this->buildSpacingGroup($componentId);
|
||||||
@@ -616,4 +617,112 @@ final class PostGridFormBuilder
|
|||||||
|
|
||||||
return $html;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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\Infrastructure\Persistence\WordPress\WordPressComponentVisibilityRepository;
|
||||||
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
|
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
|
||||||
use ROITheme\Shared\Infrastructure\Wordpress\BodyClassHooksRegistrar;
|
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
|
* DIContainer - Contenedor de Inyección de Dependencias
|
||||||
@@ -493,4 +499,47 @@ final class DIContainer
|
|||||||
}
|
}
|
||||||
return $this->instances['bodyClassHooksRegistrar'];
|
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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,15 +61,35 @@ final class WordPressComponentVisibilityRepository implements WrapperVisibilityC
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@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)
|
* - Visibilidad por tipo de página (home, posts, pages, archives, search)
|
||||||
* - Exclusiones por categoría, post ID, URL pattern
|
* - Exclusiones por categoría, post ID, URL pattern
|
||||||
*/
|
*/
|
||||||
public function isNotExcluded(string $componentName): bool
|
public function isNotExcluded(string $componentName): bool
|
||||||
{
|
{
|
||||||
|
// Verificar hide_for_logged_in
|
||||||
|
if ($this->shouldHideForLoggedIn($componentName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return PageVisibilityHelper::shouldShow($componentName);
|
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
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -562,3 +562,22 @@ function roi_get_adsense_search_config(): array {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST GRID SHORTCODE [roi_post_grid]
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra el shortcode [roi_post_grid] para mostrar grids de posts
|
||||||
|
* en cualquier pagina o entrada.
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* [roi_post_grid]
|
||||||
|
* [roi_post_grid category="precios-unitarios" posts_per_page="6"]
|
||||||
|
* [roi_post_grid id="grid-1" category="cursos" show_pagination="true"]
|
||||||
|
*
|
||||||
|
* @see Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar
|
||||||
|
*/
|
||||||
|
add_action('init', function() {
|
||||||
|
\ROITheme\Shared\Infrastructure\Wordpress\PostGridShortcodeRegistrar::register();
|
||||||
|
});
|
||||||
|
|||||||
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
|
||||||
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