Files
roi-theme/Public/TopNotificationBar/Infrastructure/Ui/TopNotificationBarRenderer.php
FrankZamora ce0179a134 feat: implement is_critical CSS injection via CriticalCSSService
- Created CriticalCSSService (singleton) that queries BD directly in wp_head
- Service generates CSS BEFORE components render (priority 1)
- Renderers check is_critical flag and skip inline CSS if true
- Made generateCSS() public in Renderers for CriticalCSSService to use
- Removed CriticalCSSCollector pattern (timing issue with WordPress)

Flow:
1. wp_head (priority 1) → CriticalCSSService::render()
2. Service queries BD for components with visibility.is_critical=true
3. Generates CSS using Renderer->generateCSS() methods
4. Outputs: <style id="roi-critical-css">...</style>
5. When Renderers execute, they detect is_critical and omit CSS inline

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

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

471 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Public\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* Class TopNotificationBarRenderer
*
* Renderizador del componente Top Notification Bar para el frontend.
*
* Responsabilidades:
* - Renderizar HTML del componente top-notification-bar
* - Delegar generación de CSS a CSSGeneratorInterface
* - Validar visibilidad (is_enabled, show_on_pages, hide_on_mobile)
* - Manejar visibilidad responsive con clases Bootstrap
* - Generar script para funcionalidad dismissible
* - Sanitizar todos los outputs
*
* NO responsable de:
* - Generar string CSS (delega a CSSGeneratorService)
* - Persistir datos (ya están en Component)
* - Lógica de negocio (está en Domain)
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar este componente)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\TopNotificationBar\Infrastructure\Ui
*/
final class TopNotificationBarRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
/**
* {@inheritDoc}
*/
public function render(Component $component): string
{
$data = $component->getData();
// Validar visibilidad general
if (!$this->isEnabled($data)) {
return '';
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
// Generar HTML
$html = $this->buildHTML($data);
// Si is_critical=true, CSS ya fue inyectado en <head> por CriticalCSSService
$isCritical = $data['visibility']['is_critical'] ?? false;
if ($isCritical) {
return $html; // Solo HTML, sin CSS inline
}
// CSS inline para componentes no críticos
$css = $this->generateCSS($data);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
/**
* {@inheritDoc}
*/
public function supports(string $componentType): bool
{
return $componentType === 'top-notification-bar';
}
/**
* Verificar si el componente está habilitado
*
* @param array $data Datos del componente
* @return bool
*/
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
'custom' => $this->isInCustomPages($data),
default => true,
};
}
/**
* Verificar si está en páginas personalizadas
*
* @param array $data Datos del componente
* @return bool
*/
private function isInCustomPages(array $data): bool
{
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
if (empty($pageIds)) {
return false;
}
$allowedIds = array_map('trim', explode(',', $pageIds));
$currentId = (string) get_the_ID();
return in_array($currentId, $allowedIds, true);
}
/**
* Verificar si el componente fue dismissed por el usuario
*
* @param array $data Datos del componente
* @return bool
*/
private function isDismissed(array $data): bool
{
if (!$this->isDismissible($data)) {
return false;
}
$cookieName = 'roi_notification_bar_dismissed';
return isset($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] === '1';
}
/**
* Verificar si el componente es dismissible
*
* @param array $data Datos del componente
* @return bool
*/
private function isDismissible(array $data): bool
{
return ($data['behavior']['is_dismissible'] ?? false) === true;
}
/**
* Generar CSS usando CSSGeneratorService
*
* Este método es público para que CriticalCSSService pueda
* generar CSS crítico antes de wp_head sin duplicar lógica.
*
* @param array $data Datos del componente
* @return string CSS generado
*/
public function generateCSS(array $data): string
{
$css = '';
// Estilos base de la barra
$baseStyles = [
'background_color' => $data['styles']['background_color'] ?? '#0E2337',
'color' => $data['styles']['text_color'] ?? '#FFFFFF',
'font_size' => $data['styles']['font_size'] ?? '0.9rem',
'padding' => $data['styles']['padding'] ?? '0.5rem 0',
'width' => '100%',
'z_index' => '1050',
];
$css .= $this->cssGenerator->generate('.top-notification-bar', $baseStyles);
// Estilos del ícono
$iconStyles = [
'color' => $data['styles']['icon_color'] ?? '#FF8600',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-icon', $iconStyles);
// Estilos de la etiqueta (label)
$labelStyles = [
'color' => $data['styles']['label_color'] ?? '#FF8600',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-label', $labelStyles);
// Estilos del enlace
$linkStyles = [
'color' => $data['styles']['link_color'] ?? '#FFFFFF',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-link', $linkStyles);
// Estilos del enlace hover
$linkHoverStyles = [
'color' => $data['styles']['link_hover_color'] ?? '#FF8600',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-link:hover', $linkHoverStyles);
// Estilos del ícono personalizado
$customIconStyles = [
'width' => '24px',
'height' => '24px',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .custom-icon', $customIconStyles);
return $css;
}
/**
* Generar HTML del componente
*
* @param array $data Datos del componente
* @return string HTML generado
*/
private function buildHTML(array $data): string
{
$classes = $this->buildClasses($data);
$content = $this->buildContent($data);
return sprintf(
'<div class="%s">%s</div>',
esc_attr($classes),
$content
);
}
/**
* Construir clases CSS del componente
*
* @param array $data Datos del componente
* @return string Clases CSS
*/
private function buildClasses(array $data): string
{
return 'top-notification-bar';
}
/**
* Construir atributos data para dismissible
*
* @param array $data Datos del componente
* @return string Atributos HTML
*/
private function buildDismissAttributes(array $data): string
{
if (!$this->isDismissible($data)) {
return '';
}
$days = (int) ($data['behavior']['dismissible_cookie_days'] ?? 7);
return sprintf(' data-dismissible-days="%d"', $days);
}
/**
* Construir contenido del componente
*
* @param array $data Datos del componente
* @return string HTML del contenido
*/
private function buildContent(array $data): string
{
$html = '<div class="container">';
$html .= '<div class="d-flex align-items-center justify-content-center">';
// Ícono
$html .= $this->buildIcon($data);
// Texto del anuncio
$html .= $this->buildAnnouncementText($data);
// Enlace
$html .= $this->buildLink($data);
$html .= '</div>';
$html .= '</div>';
return $html;
}
/**
* Construir ícono del componente
*
* @param array $data Datos del componente
* @return string HTML del ícono
*/
private function buildIcon(array $data): string
{
// Siempre usar Bootstrap icon desde content.icon_class
$iconClass = $data['content']['icon_class'] ?? 'bi-megaphone-fill';
// Asegurar prefijo 'bi-'
if (strpos($iconClass, 'bi-') !== 0) {
$iconClass = 'bi-' . $iconClass;
}
return sprintf(
'<i class="bi %s notification-icon me-2"></i>',
esc_attr($iconClass)
);
}
/**
* Construir texto del anuncio
*
* @param array $data Datos del componente
* @return string HTML del texto
*/
private function buildAnnouncementText(array $data): string
{
$label = $data['content']['label_text'] ?? '';
$text = $data['content']['message_text'] ?? '';
if (empty($text)) {
return '';
}
$html = '<span>';
if (!empty($label)) {
$html .= sprintf('<strong class="notification-label">%s</strong> ', esc_html($label));
}
$html .= esc_html($text);
$html .= '</span>';
return $html;
}
/**
* Construir enlace de acción
*
* @param array $data Datos del componente
* @return string HTML del enlace
*/
private function buildLink(array $data): string
{
$linkText = $data['content']['link_text'] ?? '';
$linkUrl = $data['content']['link_url'] ?? '#';
if (empty($linkText)) {
return '';
}
return sprintf(
'<a href="%s" class="notification-link ms-2 text-decoration-underline">%s</a>',
esc_url($linkUrl),
esc_html($linkText)
);
}
/**
* Construir botón de cerrar
*
* @return string HTML del botón
*/
private function buildDismissButton(): string
{
return '<button type="button" class="btn-close btn-close-white ms-3 roi-dismiss-notification" aria-label="Cerrar"></button>';
}
/**
* Construir estilos CSS de animaciones
*
* @param array $data Datos del componente
* @return string CSS de animaciones
*/
private function buildAnimationStyles(array $data): string
{
$animationType = $data['visual_effects']['animation_type'] ?? 'slide-down';
$animations = [
'slide-down' => [
'keyframes' => '@keyframes roiSlideDown { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }',
'animation' => 'roiSlideDown 0.5s ease-out',
],
'fade-in' => [
'keyframes' => '@keyframes roiFadeIn { from { opacity: 0; } to { opacity: 1; } }',
'animation' => 'roiFadeIn 0.5s ease-out',
],
];
$anim = $animations[$animationType] ?? $animations['slide-down'];
return sprintf(
"%s\n.top-notification-bar.roi-animated.roi-%s { animation: %s; }",
$anim['keyframes'],
$animationType,
$anim['animation']
);
}
/**
* Construir script para funcionalidad dismissible
*
* @param array $data Datos del componente
* @return string JavaScript
*/
private function buildDismissScript(array $data): string
{
$days = (int) ($data['behavior']['dismissible_cookie_days'] ?? 7);
return sprintf(
'<script>
document.addEventListener("DOMContentLoaded", function() {
const dismissBtn = document.querySelector(".roi-dismiss-notification");
if (dismissBtn) {
dismissBtn.addEventListener("click", function() {
const bar = document.querySelector(".top-notification-bar");
if (bar) {
bar.style.display = "none";
}
const days = %d;
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + date.toUTCString();
document.cookie = "roi_notification_bar_dismissed=1;" + expires + ";path=/";
});
}
});
</script>',
$days
);
}
/**
* Obtiene las clases CSS de Bootstrap para visibilidad responsive
*
* Implementa tabla de decisión según especificación (10.03):
* - Desktop Y Mobile = null (visible en ambos)
* - Solo Desktop = 'd-none d-lg-block'
* - Solo Mobile = 'd-lg-none'
* - Ninguno = 'd-none' (oculto)
*
* @param bool $desktop Mostrar en desktop
* @param bool $mobile Mostrar en mobile
* @return string|null Clases CSS o null si visible en ambos
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
// Desktop Y Mobile = visible en ambos dispositivos
if ($desktop && $mobile) {
return null; // Sin clases = visible siempre
}
// Solo Desktop
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
// Solo Mobile
if (!$desktop && $mobile) {
return 'd-lg-none';
}
// Ninguno = oculto completamente
return 'd-none';
}
}