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:
FrankZamora
2025-12-06 21:33:20 -06:00
parent c23dc22d76
commit 79e91f59ee
13 changed files with 1532 additions and 2 deletions

View File

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