Files
roi-theme/Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
FrankZamora fb68f2023c fix(theme): improve post-grid spacing, pagination and archive templates
- Fix flexbox gap issue causing unequal horizontal/vertical spacing
- Reset Bootstrap row/col margins to use only CSS gap property
- Replace WordPress pagination with Bootstrap-style pagination
- Add cta-post component to category.php and archive.php templates
- Fix spacing controls UI with separate horizontal/vertical gap fields
- Update FieldMapper with new gap_horizontal and gap_vertical attributes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:26:19 -06:00

652 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Public\PostGrid\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* PostGridRenderer - Renderiza grid de posts del loop principal de WordPress
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Post Grid
*
* DIFERENCIA CON RelatedPostRenderer:
* - PostGrid usa global $wp_query (loop principal)
* - RelatedPost crea su propio WP_Query
*
* CARACTERISTICAS:
* - Grid responsive de cards con imagen, excerpt y meta
* - Usa loop principal de WordPress (no crea queries propias)
* - Paginacion nativa de WordPress
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\PostGrid\Infrastructure\Ui
*/
final class PostGridRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'post-grid';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
global $wp_query;
// Si no hay posts, mostrar mensaje
if (!have_posts()) {
$noPostsMessage = $data['content']['no_posts_message'] ?? 'No se encontraron publicaciones';
return $this->renderNoPostsMessage($noPostsMessage, $visibilityClass, $data);
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function renderNoPostsMessage(string $message, string $visibilityClass, array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
$textColor = $colors['excerpt_color'] ?? '#6b7280';
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
$padding = $spacing['card_padding'] ?? '1.25rem';
$css = $this->cssGenerator->generate('.post-grid-no-posts', [
'background-color' => $bgColor,
'color' => $textColor,
'border' => "1px solid {$borderColor}",
'border-radius' => '0.5rem',
'padding' => '2rem',
'text-align' => 'center',
]);
$containerClass = 'post-grid-no-posts';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf(
'<div class="%s"><p class="mb-0">%s</p></div>',
esc_attr($containerClass),
esc_html($message)
);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$typography = $data['typography'] ?? [];
$layout = $data['layout'] ?? [];
$cssRules = [];
// Colores
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
$metaColor = $colors['meta_color'] ?? '#9ca3af';
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
$paginationColor = $colors['pagination_color'] ?? '#0E2337';
$paginationActiveBg = $colors['pagination_active_bg'] ?? '#FF8600';
$paginationActiveColor = $colors['pagination_active_color'] ?? '#ffffff';
// Spacing
$gapHorizontal = $spacing['gap_horizontal'] ?? '24px';
$gapVertical = $spacing['gap_vertical'] ?? '24px';
$cardPadding = $spacing['card_padding'] ?? '20px';
$sectionMarginTop = $spacing['section_margin_top'] ?? '0px';
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '32px';
// Visual effects
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
$imageBorderRadius = $effects['image_border_radius'] ?? '0.375rem';
// Typography
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
$metaSize = $typography['meta_size'] ?? '0.8rem';
// Container
$cssRules[] = $this->cssGenerator->generate('.post-grid', [
'margin-top' => $sectionMarginTop,
'margin-bottom' => $sectionMarginBottom,
]);
// Row: usar display flex con gap, quitar margins/paddings de Bootstrap
$cssRules[] = ".post-grid .row {
display: flex;
flex-wrap: wrap;
column-gap: {$gapHorizontal};
row-gap: {$gapVertical};
margin: 0;
padding: 0;
}";
// Columnas: quitar padding de Bootstrap y margin-bottom
$cssRules[] = ".post-grid .post-card-col {
padding: 0;
margin: 0;
}";
// Card base - sin margin extra
$cssRules[] = ".post-grid .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
border-radius: {$cardBorderRadius};
box-shadow: {$cardShadow};
transition: {$cardTransition};
height: 100%;
overflow: hidden;
margin: 0;
}";
// Card hover
$cssRules[] = ".post-grid .card:hover {
background: {$cardHoverBgColor};
border-color: {$cardHoverBorderColor};
box-shadow: {$cardHoverShadow};
transform: translateY(-2px);
}";
// Card body
$cssRules[] = $this->cssGenerator->generate('.post-grid .card-body', [
'padding' => $cardPadding,
]);
// Card image
$cssRules[] = ".post-grid .card-img-top {
border-radius: {$imageBorderRadius} {$imageBorderRadius} 0 0;
object-fit: cover;
width: 100%;
height: 200px;
}";
// Card title
$cssRules[] = ".post-grid .card-title {
color: {$cardTitleColor};
font-size: {$cardTitleSize};
font-weight: {$cardTitleWeight};
line-height: 1.4;
margin-bottom: 0.75rem;
}";
// Card title hover
$cssRules[] = ".post-grid a:hover .card-title {
color: {$cardHoverBorderColor};
}";
// Excerpt
$cssRules[] = ".post-grid .card-text {
color: {$excerptColor};
font-size: {$excerptSize};
line-height: 1.6;
}";
// Meta
$cssRules[] = ".post-grid .post-meta {
color: {$metaColor};
font-size: {$metaSize};
}";
// Categories
$cssRules[] = ".post-grid .post-category {
background: {$categoryBgColor};
color: {$categoryTextColor};
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
text-decoration: none;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}";
$cssRules[] = ".post-grid .post-category:hover {
background: {$categoryTextColor};
color: #ffffff;
}";
// Pagination
$cssRules[] = ".post-grid .pagination {
margin-top: 2rem;
}";
$cssRules[] = ".post-grid .page-link {
color: {$paginationColor};
border: 1px solid {$cardBorderColor};
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
}";
$cssRules[] = ".post-grid .page-link:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$paginationActiveBg};
color: {$paginationActiveBg};
}";
$cssRules[] = ".post-grid .page-item.active .page-link,
.post-grid .nav-links .current {
background-color: {$paginationActiveBg};
border-color: {$paginationActiveBg};
color: {$paginationActiveColor};
}";
// WordPress pagination classes
$cssRules[] = ".post-grid .nav-links {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 2rem;
}";
$cssRules[] = ".post-grid .nav-links a,
.post-grid .nav-links span {
color: {$paginationColor};
border: 1px solid {$cardBorderColor};
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
}";
$cssRules[] = ".post-grid .nav-links a:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$paginationActiveBg};
color: {$paginationActiveBg};
}";
// Layout responsive columns
$colsDesktop = $layout['columns_desktop'] ?? '3';
$colsTablet = $layout['columns_tablet'] ?? '2';
$colsMobile = $layout['columns_mobile'] ?? '1';
// Mobile (1 col = no gap needed)
$mobileWidth = $this->getColumnWidth($colsMobile, $gapHorizontal);
$cssRules[] = "@media (max-width: 575.98px) {
.post-grid .post-card-col {
flex: 0 0 {$mobileWidth};
max-width: {$mobileWidth};
}
}";
// Tablet
$tabletWidth = $this->getColumnWidth($colsTablet, $gapHorizontal);
$cssRules[] = "@media (min-width: 576px) and (max-width: 991.98px) {
.post-grid .post-card-col {
flex: 0 0 {$tabletWidth};
max-width: {$tabletWidth};
}
}";
// Desktop
$desktopWidth = $this->getColumnWidth($colsDesktop, $gapHorizontal);
$cssRules[] = "@media (min-width: 992px) {
.post-grid .post-card-col {
flex: 0 0 {$desktopWidth};
max-width: {$desktopWidth};
}
}";
return implode("\n", $cssRules);
}
/**
* Calcula el ancho de columna considerando el gap
*
* Con gap en flexbox, el ancho debe ser:
* (100% - (n-1)*gap) / n
*
* @param string $cols Número de columnas
* @param string $gap Valor del gap (ej: '1.5rem', '24px')
* @return string Valor CSS con calc() si hay gap
*/
private function getColumnWidth(string $cols, string $gap): string
{
$colCount = (int)$cols;
if ($colCount <= 0) {
$colCount = 1;
}
// Si es 1 columna, no hay gap entre columnas
if ($colCount === 1) {
return '100%';
}
// Número de gaps = columnas - 1
$gapCount = $colCount - 1;
// calc((100% - (n-1)*gap) / n)
return sprintf('calc((100%% - %d * %s) / %d)', $gapCount, $gap, $colCount);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$typography = $data['typography'] ?? [];
$media = $data['media'] ?? [];
$layout = $data['layout'] ?? [];
$showThumbnail = $this->toBool($content['show_thumbnail'] ?? true);
$showExcerpt = $this->toBool($content['show_excerpt'] ?? true);
$showMeta = $this->toBool($content['show_meta'] ?? true);
$showCategories = $this->toBool($content['show_categories'] ?? true);
$excerptLength = (int)($content['excerpt_length'] ?? 20);
$readMoreText = $content['read_more_text'] ?? 'Leer mas';
$headingLevel = $typography['heading_level'] ?? 'h3';
$fallbackImage = $media['fallback_image'] ?? '';
$fallbackImageAlt = $media['fallback_image_alt'] ?? 'Imagen por defecto';
$imagePosition = $layout['image_position'] ?? 'top';
$containerClass = 'post-grid';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= '<div class="row">';
while (have_posts()) {
the_post();
$html .= $this->buildCardHTML(
$showThumbnail,
$showExcerpt,
$showMeta,
$showCategories,
$excerptLength,
$readMoreText,
$headingLevel,
$fallbackImage,
$fallbackImageAlt,
$imagePosition
);
}
$html .= '</div>';
// Paginacion nativa de WordPress
$html .= '<div class="pagination-wrapper">';
$html .= $this->buildPaginationHTML();
$html .= '</div>';
$html .= '</div>';
wp_reset_postdata();
return $html;
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
private function buildCardHTML(
bool $showThumbnail,
bool $showExcerpt,
bool $showMeta,
bool $showCategories,
int $excerptLength,
string $readMoreText,
string $headingLevel,
string $fallbackImage,
string $fallbackImageAlt,
string $imagePosition
): string {
$permalink = get_permalink();
$title = get_the_title();
$html = '<div class="post-card-col">';
$html .= sprintf(
'<a href="%s" class="text-decoration-none">',
esc_url($permalink)
);
$cardClass = 'card h-100';
if ($imagePosition === 'left') {
$cardClass .= ' flex-row';
}
$html .= sprintf('<div class="%s">', esc_attr($cardClass));
// Imagen
if ($showThumbnail && $imagePosition !== 'none') {
$html .= $this->buildImageHTML($fallbackImage, $fallbackImageAlt, $imagePosition);
}
$html .= '<div class="card-body">';
// Categorias
if ($showCategories) {
$html .= $this->buildCategoriesHTML();
}
// Titulo
$html .= sprintf(
'<%s class="card-title">%s</%s>',
esc_attr($headingLevel),
esc_html($title),
esc_attr($headingLevel)
);
// Meta
if ($showMeta) {
$html .= $this->buildMetaHTML();
}
// Excerpt
if ($showExcerpt) {
$html .= $this->buildExcerptHTML($excerptLength);
}
$html .= '</div>'; // card-body
$html .= '</div>'; // card
$html .= '</a>';
$html .= '</div>'; // col
return $html;
}
private function buildImageHTML(string $fallbackImage, string $fallbackImageAlt, string $imagePosition): string
{
if (has_post_thumbnail()) {
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
return get_the_post_thumbnail(
null,
'medium_large',
['class' => $imageClass, 'loading' => 'lazy']
);
}
if (!empty($fallbackImage)) {
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
return sprintf(
'<img src="%s" alt="%s" class="%s" loading="lazy">',
esc_url($fallbackImage),
esc_attr($fallbackImageAlt),
esc_attr($imageClass)
);
}
return '';
}
private function buildCategoriesHTML(): string
{
$categories = get_the_category();
if (empty($categories)) {
return '';
}
$html = '<div class="post-categories mb-2">';
foreach (array_slice($categories, 0, 2) as $category) {
$html .= sprintf(
'<span class="post-category">%s</span>',
esc_html($category->name)
);
}
$html .= '</div>';
return $html;
}
private function buildMetaHTML(): string
{
$date = get_the_date();
$author = get_the_author();
return sprintf(
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
esc_html($date),
esc_html($author)
);
}
private function buildExcerptHTML(int $length): string
{
$excerpt = get_the_excerpt();
if (empty($excerpt)) {
$excerpt = wp_trim_words(get_the_content(), $length, '...');
} else {
$excerpt = wp_trim_words($excerpt, $length, '...');
}
return sprintf(
'<p class="card-text">%s</p>',
esc_html($excerpt)
);
}
private function buildPaginationHTML(): string
{
global $wp_query;
$totalPages = $wp_query->max_num_pages;
if ($totalPages <= 1) {
return '';
}
$currentPage = max(1, get_query_var('paged', 1));
$html = '<nav aria-label="Paginacion"><ul class="pagination justify-content-center">';
// Boton Inicio (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Inicio</a></li>',
esc_url(get_pagenum_link(1))
);
// Numeros de pagina - mostrar 5 paginas
$visiblePages = 5;
$start = max(1, $currentPage - 2);
$end = min($totalPages, $start + $visiblePages - 1);
// Ajustar inicio si estamos cerca del final
if ($end - $start < $visiblePages - 1) {
$start = max(1, $end - $visiblePages + 1);
}
for ($i = $start; $i <= $end; $i++) {
if ($i === $currentPage) {
$html .= sprintf(
'<li class="page-item active"><span class="page-link">%d</span></li>',
$i
);
} else {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">%d</a></li>',
esc_url(get_pagenum_link($i)),
$i
);
}
}
// Ver mas (siguiente pagina)
if ($currentPage < $totalPages) {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Ver mas</a></li>',
esc_url(get_pagenum_link($currentPage + 1))
);
}
// Boton Fin (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Fin</a></li>',
esc_url(get_pagenum_link($totalPages))
);
$html .= '</ul></nav>';
return $html;
}
}