Files
roi-theme/Public/Hero/Infrastructure/Ui/HeroRenderer.php
FrankZamora 4f25297f14 feat(pagespeed): implementar campo is_critical para CSS crítico dinámico (Phase 4.2)
Implementación completa del sistema de Critical CSS dinámico según plan 13.01:

Domain Layer:
- Crear CriticalCSSCollectorInterface para DIP compliance

Infrastructure Layer:
- Implementar CriticalCSSCollector (singleton via DIContainer)
- Crear CriticalCSSHooksRegistrar para inyección en wp_head
- Actualizar DIContainer con getCriticalCSSCollector()

Schemas:
- Agregar campo is_critical a navbar, top-notification-bar, hero
- Sincronizar con BD (18+39+31 campos)

Renderers (navbar, top-notification-bar, hero):
- Inyectar CriticalCSSCollectorInterface via constructor
- Lógica condicional: si is_critical=true → CSS a <head>

Admin (FormBuilders + FieldMappers):
- Toggle "CSS Crítico" en sección visibility
- Mapeo AJAX para persistencia

Beneficios:
- LCP optimizado: CSS crítico inline en <head>
- Above-the-fold rendering sin FOUC
- Componentes configurables desde admin panel

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 09:29:45 -06:00

302 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Public\Hero\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* Class HeroRenderer
*
* Renderizador del componente Hero para el frontend.
*
* Responsabilidades:
* - Renderizar HTML del hero section con título del post/página
* - Mostrar badges de categorías (dinámicos desde WordPress)
* - Delegar generación de CSS a CSSGeneratorInterface
* - Validar visibilidad (is_enabled, show_on_pages, responsive)
* - Manejar visibilidad responsive con clases Bootstrap
*
* NO responsable de:
* - Generar string CSS (delega a CSSGeneratorService)
* - Persistir datos
* - Lógica de negocio
*
* @package ROITheme\Public\Hero\Infrastructure\Ui
*/
final class HeroRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
* @param CriticalCSSCollectorInterface $criticalCollector Colector de CSS crítico
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator,
private CriticalCSSCollectorInterface $criticalCollector
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
// Verificar si el CSS debe ser crítico (inyectado en <head>)
$isCritical = isset($data['visibility']['is_critical']) &&
$data['visibility']['is_critical'] === true;
if ($isCritical) {
// CSS crítico: agregar al collector para inyección en <head>
$this->criticalCollector->add('hero', $css);
return $html; // Solo HTML, CSS se inyecta en <head>
}
// CSS no crítico: incluir inline con el componente
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'hero';
}
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'home':
return is_front_page() || is_home();
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$typography = $data['typography'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$gradientStart = $colors['gradient_start'] ?? '#1e3a5f';
$gradientEnd = $colors['gradient_end'] ?? '#2c5282';
$titleColor = $colors['title_color'] ?? '#FFFFFF';
$badgeBgColor = $colors['badge_bg_color'] ?? '#FFFFFF';
$badgeTextColor = $colors['badge_text_color'] ?? '#FFFFFF';
$badgeIconColor = $colors['badge_icon_color'] ?? '#FFB800';
$badgeHoverBg = $colors['badge_hover_bg'] ?? '#FF8600';
$titleFontSize = $typography['title_font_size'] ?? '2.5rem';
$titleFontSizeMobile = $typography['title_font_size_mobile'] ?? '1.75rem';
$titleFontWeight = $typography['title_font_weight'] ?? '700';
$titleLineHeight = $typography['title_line_height'] ?? '1.4';
$badgeFontSize = $typography['badge_font_size'] ?? '0.813rem';
$paddingVertical = $spacing['padding_vertical'] ?? '3rem';
$marginBottom = $spacing['margin_bottom'] ?? '1.5rem';
$badgePadding = $spacing['badge_padding'] ?? '0.375rem 0.875rem';
$badgeBorderRadius = $spacing['badge_border_radius'] ?? '20px';
$minHeight = $spacing['min_height'] ?? '120px';
$titleMinHeight = $spacing['title_min_height'] ?? '3rem';
$badgeMinHeight = $spacing['badge_min_height'] ?? '32px';
$boxShadow = $effects['box_shadow'] ?? '0 4px 16px rgba(30, 58, 95, 0.25)';
$titleTextShadow = $effects['title_text_shadow'] ?? '1px 1px 2px rgba(0, 0, 0, 0.2)';
$badgeBackdropBlur = $effects['badge_backdrop_blur'] ?? '10px';
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$cssRules = [];
$cssRules[] = $this->cssGenerator->generate('.hero-section', [
'background' => "linear-gradient(135deg, {$gradientStart} 0%, {$gradientEnd} 100%)",
'box-shadow' => $boxShadow,
'padding' => "{$paddingVertical} 0",
'margin-bottom' => $marginBottom,
'min-height' => $minHeight,
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__title', [
'color' => "{$titleColor} !important",
'font-weight' => $titleFontWeight,
'font-size' => $titleFontSize,
'line-height' => $titleLineHeight,
'text-shadow' => $titleTextShadow,
'margin-bottom' => '0',
'text-align' => 'center',
'min-height' => $titleMinHeight,
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__badge', [
'background' => $this->hexToRgba($badgeBgColor, 0.15),
'backdrop-filter' => "blur({$badgeBackdropBlur})",
'-webkit-backdrop-filter' => "blur({$badgeBackdropBlur})",
'border' => '1px solid ' . $this->hexToRgba($badgeBgColor, 0.2),
'color' => $this->hexToRgba($badgeTextColor, 0.95),
'padding' => $badgePadding,
'border-radius' => $badgeBorderRadius,
'font-size' => $badgeFontSize,
'font-weight' => '500',
'text-decoration' => 'none',
'display' => 'inline-block',
'transition' => 'all 0.3s ease',
'min-height' => $badgeMinHeight,
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__badge:hover', [
'background' => $this->hexToRgba($badgeHoverBg, 0.2),
'border-color' => $this->hexToRgba($badgeHoverBg, 0.4),
'color' => '#ffffff',
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__badge i', [
'color' => $badgeIconColor,
]);
$cssRules[] = "@media (max-width: 767.98px) {
.hero-section__title {
font-size: {$titleFontSizeMobile};
}
}";
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 767.98px) {
.hero-section { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 768px) {
.hero-section { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$showCategories = $content['show_categories'] ?? true;
$showBadgeIcon = $content['show_badge_icon'] ?? true;
$badgeIconClass = $content['badge_icon_class'] ?? 'bi-folder-fill';
$titleTag = $content['title_tag'] ?? 'h1';
$allowedTags = ['h1', 'h2', 'div'];
if (!in_array($titleTag, $allowedTags, true)) {
$titleTag = 'h1';
}
$title = is_singular() ? get_the_title() : '';
if (empty($title)) {
$title = wp_title('', false);
}
$html = '<div class="container-fluid hero-section">';
$html .= '<div class="container">';
if ($showCategories && is_single()) {
$categories = get_the_category();
if (!empty($categories)) {
$html .= '<div class="mb-3 d-flex justify-content-center">';
$html .= '<div class="d-flex gap-2 flex-wrap justify-content-center">';
foreach ($categories as $category) {
$categoryLink = esc_url(get_category_link($category->term_id));
$categoryName = esc_html($category->name);
$iconHtml = $showBadgeIcon
? '<i class="bi ' . esc_attr($badgeIconClass) . ' me-1"></i>'
: '';
$html .= sprintf(
'<a href="%s" class="hero-section__badge">%s%s</a>',
$categoryLink,
$iconHtml,
$categoryName
);
}
$html .= '</div>';
$html .= '</div>';
}
}
$html .= sprintf(
'<%s class="hero-section__title">%s</%s>',
$titleTag,
esc_html($title),
$titleTag
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function hexToRgba(string $hex, float $alpha): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return "rgba({$r}, {$g}, {$b}, {$alpha})";
}
/**
* Genera clases Bootstrap de visibilidad responsive
*
* @param bool $desktop Si debe mostrarse en desktop
* @param bool $mobile Si debe mostrarse en mobile
* @return string|null Clases Bootstrap o null si visible en todos
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if ($desktop && $mobile) {
return null;
}
if ($desktop && !$mobile) {
return 'd-none d-md-block';
}
if (!$desktop && $mobile) {
return 'd-block d-md-none';
}
return 'd-none';
}
}