diff --git a/Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php b/Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php index e99112c1..c9c4e396 100644 --- a/Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php +++ b/Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php @@ -33,14 +33,15 @@ final class PostGridFormBuilder // Columna izquierda $html .= '
'; + $html .= $this->buildShortcodeGuide(); $html .= $this->buildVisibilityGroup($componentId); $html .= $this->buildContentGroup($componentId); - $html .= $this->buildLayoutGroup($componentId); $html .= $this->buildMediaGroup($componentId); $html .= '
'; // Columna derecha $html .= '
'; + $html .= $this->buildLayoutGroup($componentId); $html .= $this->buildTypographyGroup($componentId); $html .= $this->buildColorsGroup($componentId); $html .= $this->buildSpacingGroup($componentId); @@ -616,4 +617,112 @@ final class PostGridFormBuilder return $html; } + + private function buildShortcodeGuide(): string + { + $html = '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' Shortcode [roi_post_grid]'; + $html .= '
'; + + $html .= '

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

'; + + // Uso basico + $html .= '

'; + $html .= ' '; + $html .= ' Uso basico (9 posts, 3 columnas)'; + $html .= '

'; + $html .= '
'; + $html .= ' [roi_post_grid]'; + $html .= '
'; + + // Por categoria + $html .= '

'; + $html .= ' '; + $html .= ' Filtrar por categoria'; + $html .= '

'; + $html .= '
'; + $html .= ' [roi_post_grid category="precios-unitarios"]'; + $html .= '
'; + + // Personalizar cantidad y columnas + $html .= '

'; + $html .= ' '; + $html .= ' 6 posts en 2 columnas'; + $html .= '

'; + $html .= '
'; + $html .= ' [roi_post_grid posts_per_page="6" columns="2"]'; + $html .= '
'; + + // Con paginacion + $html .= '

'; + $html .= ' '; + $html .= ' Con paginacion'; + $html .= '

'; + $html .= '
'; + $html .= ' [roi_post_grid posts_per_page="12" show_pagination="true"]'; + $html .= '
'; + + // Filtrar por tag + $html .= '

'; + $html .= ' '; + $html .= ' Filtrar por etiqueta'; + $html .= '

'; + $html .= '
'; + $html .= ' [roi_post_grid tag="tutorial"]'; + $html .= '
'; + + // Ejemplo completo + $html .= '

'; + $html .= ' '; + $html .= ' Ejemplo completo'; + $html .= '

'; + $html .= '
'; + $html .= ' [roi_post_grid category="cursos" posts_per_page="6" columns="3" show_meta="false" show_categories="true"]'; + $html .= '
'; + + // Tabla de atributos + $html .= '
'; + $html .= '

'; + $html .= ' '; + $html .= ' Atributos disponibles'; + $html .= '

'; + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= '
AtributoDefaultDescripcion
posts_per_page9Cantidad de posts
columns3Columnas (1-4)
category-Slug de categoria
exclude_category-Excluir categoria
tag-Slug de etiqueta
author-ID o username
orderbydatedate, title, rand
orderDESCDESC o ASC
show_paginationfalseMostrar paginacion
show_thumbnailtrueMostrar imagen
show_excerpttrueMostrar extracto
show_metatrueFecha y autor
show_categoriestrueBadges categoria
excerpt_length20Palabras extracto
exclude_posts-IDs separados por coma
offset0Saltar N posts
id-ID unico (multiples grids)
class-Clase CSS adicional
'; + $html .= '
'; + + $html .= '
'; + $html .= '
'; + + return $html; + } } diff --git a/Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php b/Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php new file mode 100644 index 00000000..8b888827 --- /dev/null +++ b/Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php @@ -0,0 +1,143 @@ + '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 $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 + */ + 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 + */ + 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'; + } +} diff --git a/Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php b/Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php new file mode 100644 index 00000000..d2258543 --- /dev/null +++ b/Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php @@ -0,0 +1,71 @@ +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 + */ + private function getSettings(): array + { + $settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME); + + if (empty($settings)) { + return VisibilityDefaults::getForComponent(self::COMPONENT_NAME); + } + + return $settings; + } +} diff --git a/Shared/Domain/Contracts/PostGridQueryBuilderInterface.php b/Shared/Domain/Contracts/PostGridQueryBuilderInterface.php new file mode 100644 index 00000000..45470dc9 --- /dev/null +++ b/Shared/Domain/Contracts/PostGridQueryBuilderInterface.php @@ -0,0 +1,51 @@ + 'precios-unitarios', + * 'tag' => 'concreto', + * 'posts_per_page' => 6, + * 'orderby' => 'date', + * 'order' => 'DESC' + * ]; + * + * $query = $builder->build($params); + * ``` + * + * @param array $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; +} diff --git a/Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php b/Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php new file mode 100644 index 00000000..d5f625e8 --- /dev/null +++ b/Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php @@ -0,0 +1,50 @@ +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 $settings Settings del componente post-grid desde BD + * @param array $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; +} diff --git a/Shared/Infrastructure/Di/DIContainer.php b/Shared/Infrastructure/Di/DIContainer.php index 2ae313a8..5a474d0c 100644 --- a/Shared/Infrastructure/Di/DIContainer.php +++ b/Shared/Infrastructure/Di/DIContainer.php @@ -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']; + } } diff --git a/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php b/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php index 9ff950e4..5edb5c50 100644 --- a/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php +++ b/Shared/Infrastructure/Persistence/WordPress/WordPressComponentVisibilityRepository.php @@ -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 * diff --git a/Shared/Infrastructure/Query/PostGridQueryBuilder.php b/Shared/Infrastructure/Query/PostGridQueryBuilder.php new file mode 100644 index 00000000..09accd40 --- /dev/null +++ b/Shared/Infrastructure/Query/PostGridQueryBuilder.php @@ -0,0 +1,109 @@ + '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; + } +} diff --git a/Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php b/Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php new file mode 100644 index 00000000..fa4dbc31 --- /dev/null +++ b/Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php @@ -0,0 +1,377 @@ +have_posts()) { + return $this->renderNoPostsMessage($settings, $options); + } + + $css = $this->generateCSS($settings, $options); + $html = $this->buildHTML($query, $settings, $options); + + return sprintf("\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( + "\n

%s

", + $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('
', esc_attr($containerClass)); + $html .= '
'; + + while ($query->have_posts()) { + $query->the_post(); + $html .= $this->buildCardHTML( + $colClass, + $showThumbnail, + $showExcerpt, + $showMeta, + $showCategories, + $excerptLength + ); + } + + $html .= '
'; + + if ($showPagination && $query->max_num_pages > 1) { + $html .= $this->buildPaginationHTML($query, $options); + } + + $html .= '
'; + + 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(''; + + 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 = '
'; + foreach (array_slice($categories, 0, 2) as $category) { + $html .= sprintf( + '', + esc_html($category->name) + ); + } + $html .= '
'; + + return $html; + } + + private function buildMetaHTML(): string + { + return sprintf( + '', + 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('

%s

', 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 = ''; + + return $html; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + return $value === 'true' || $value === '1' || $value === 1; + } +} diff --git a/Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php b/Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php new file mode 100644 index 00000000..4fc3f39a --- /dev/null +++ b/Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php @@ -0,0 +1,95 @@ +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 + */ + 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); + } +} diff --git a/functions-addon.php b/functions-addon.php index 2169c389..32d96d4a 100644 --- a/functions-addon.php +++ b/functions-addon.php @@ -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(); +}); diff --git a/openspec/specs/post-grid-shortcode/spec.md b/openspec/specs/post-grid-shortcode/spec.md new file mode 100644 index 00000000..bd79019d --- /dev/null +++ b/openspec/specs/post-grid-shortcode/spec.md @@ -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 diff --git a/test-shortcode.php b/test-shortcode.php new file mode 100644 index 00000000..6947f12f --- /dev/null +++ b/test-shortcode.php @@ -0,0 +1,27 @@ + + +
+

Prueba de Shortcode [roi_post_grid]

+ +

Test 1: Grid básico (9 posts)

+ + +
+ +

Test 2: 6 posts en 2 columnas

+ + +
+ +

Test 3: 4 posts en 4 columnas sin meta

+ +
+ +