Files
roi-theme/Public/Footer/Infrastructure/Ui/FooterRenderer.php
FrankZamora 1c901ecdf9 fix(accessibility): Fix cta-post contrast and heading hierarchy
Phase 4.4 Accessibility fixes:
- cta-post: button_text_color from #ffffff to #0E2337 (WCAG AA 4.8:1)
- TableOfContentsRenderer: h4 toc-title changed to span (semantic)
- FooterRenderer: h5 widget-title changed to span (5 instances)

Fixes: "Low contrast on cta-button" and "Headings skip levels"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:59:06 -06:00

450 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Public\Footer\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* FooterRenderer - Renderiza el footer del sitio
*
* RESPONSABILIDAD: Generar HTML y CSS del footer con menus WP y newsletter
*
* SEGURIDAD:
* - Webhook URL nunca se expone al cliente
* - Escaping de todos los outputs
* - Nonce para formulario newsletter
*
* @package ROITheme\Public\Footer\Infrastructure\Ui
*/
final class FooterRenderer implements RendererInterface
{
private const NONCE_ACTION = 'roi_newsletter_nonce';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function supports(string $componentType): bool
{
return $componentType === 'footer';
}
public function render(Component $component): string
{
$data = $component->getData();
// Validar visibilidad
$visibility = $data['visibility'] ?? [];
if (!($visibility['is_enabled'] ?? true)) {
return '';
}
// Verificar visibilidad responsive
$showDesktop = $visibility['show_on_desktop'] ?? true;
$showMobile = $visibility['show_on_mobile'] ?? true;
if (!$showDesktop && !$showMobile) {
return '';
}
// Generar CSS
$css = $this->generateCSS($data, $showDesktop, $showMobile);
// Generar HTML
$html = $this->generateHTML($data);
// Generar JavaScript
$js = $this->generateJS($data);
return $css . $html . $js;
}
private function generateCSS(array $data, bool $showDesktop, bool $showMobile): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
// Valores con fallbacks
$bgColor = $colors['bg_color'] ?? '#212529';
$textColor = $colors['text_color'] ?? '#ffffff';
$titleColor = $colors['title_color'] ?? '#ffffff';
$linkColor = $colors['link_color'] ?? '#ffffff';
$linkHoverColor = $colors['link_hover_color'] ?? '#FF8600';
$inputBgColor = $colors['input_bg_color'] ?? '#ffffff';
$inputTextColor = $colors['input_text_color'] ?? '#212529';
$inputBorderColor = $colors['input_border_color'] ?? '#dee2e6';
$buttonBgColor = $colors['button_bg_color'] ?? '#0d6efd';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBg = $colors['button_hover_bg'] ?? '#0b5ed7';
$borderTopColor = $colors['border_top_color'] ?? 'rgba(255, 255, 255, 0.2)';
$paddingY = $spacing['padding_y'] ?? '3rem';
$marginTop = $spacing['margin_top'] ?? '0';
$widgetTitleMb = $spacing['widget_title_margin_bottom'] ?? '1rem';
$linkMb = $spacing['link_margin_bottom'] ?? '0.5rem';
$copyrightPy = $spacing['copyright_padding_y'] ?? '1.5rem';
$inputRadius = $effects['input_border_radius'] ?? '6px';
$buttonRadius = $effects['button_border_radius'] ?? '6px';
$transition = $effects['transition_duration'] ?? '0.3s';
$cssRules = [];
// Footer principal
$cssRules[] = $this->cssGenerator->generate('.roi-footer', [
'background-color' => $bgColor,
'color' => $textColor,
'padding-top' => $paddingY,
'padding-bottom' => $paddingY,
'margin-top' => $marginTop,
]);
// Grid custom para 3+3+3+4 = 13 columnas
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-grid', [
'display' => 'grid',
'grid-template-columns' => 'repeat(4, 1fr)',
'gap' => '2rem',
]);
// En desktop: distribucion 3+3+3+4
$cssRules[] = "@media (min-width: 768px) {
.roi-footer .footer-grid {
grid-template-columns: 23% 23% 23% 31%;
}
}";
// En mobile: 2 columnas
$cssRules[] = "@media (max-width: 767px) {
.roi-footer .footer-grid {
grid-template-columns: 1fr 1fr;
}
.roi-footer .footer-widget-newsletter {
grid-column: span 2;
}
}";
// Titulos de widgets
$cssRules[] = $this->cssGenerator->generate('.roi-footer .widget-title', [
'color' => $titleColor,
'font-size' => '1.25rem',
'font-weight' => '500',
'margin-bottom' => $widgetTitleMb,
]);
// Links de navegacion
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav', [
'list-style' => 'none',
'padding' => '0',
'margin' => '0',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav li', [
'margin-bottom' => $linkMb,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav a', [
'color' => $linkColor,
'text-decoration' => 'none',
'transition' => "color {$transition}",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav a:hover', [
'color' => $linkHoverColor,
]);
// Widget 1B spacing
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-widget-1b', [
'margin-top' => '1.5rem',
]);
// Newsletter description
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-description', [
'color' => $textColor,
'margin-bottom' => '1rem',
'opacity' => '0.9',
]);
// Input newsletter
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-input', [
'width' => '100%',
'padding' => '0.75rem 1rem',
'background-color' => $inputBgColor,
'color' => $inputTextColor,
'border' => "1px solid {$inputBorderColor}",
'border-radius' => $inputRadius,
'margin-bottom' => '0.75rem',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-input:focus', [
'outline' => 'none',
'border-color' => $buttonBgColor,
'box-shadow' => "0 0 0 0.2rem rgba(13, 110, 253, 0.25)",
]);
// Boton newsletter
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-btn', [
'width' => '100%',
'padding' => '0.75rem 1.5rem',
'background-color' => $buttonBgColor,
'color' => $buttonTextColor,
'border' => 'none',
'border-radius' => $buttonRadius,
'font-weight' => '500',
'cursor' => 'pointer',
'transition' => "background-color {$transition}",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-btn:hover', [
'background-color' => $buttonHoverBg,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-btn:disabled', [
'opacity' => '0.7',
'cursor' => 'not-allowed',
]);
// Mensaje newsletter
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-message', [
'margin-top' => '0.75rem',
'padding' => '0.5rem',
'border-radius' => '4px',
'font-size' => '0.875rem',
'display' => 'none',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-message.success', [
'background-color' => '#d1e7dd',
'color' => '#0f5132',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-message.error', [
'background-color' => '#f8d7da',
'color' => '#842029',
]);
// Footer bottom (copyright)
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-bottom', [
'border-top' => "1px solid {$borderTopColor}",
'padding-top' => $copyrightPy,
'margin-top' => '2rem',
'text-align' => 'center',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .copyright-text', [
'margin' => '0',
'opacity' => '0.9',
]);
// Responsive visibility
if (!$showDesktop) {
$cssRules[] = "@media (min-width: 992px) { .roi-footer { display: none !important; } }";
}
if (!$showMobile) {
$cssRules[] = "@media (max-width: 991px) { .roi-footer { display: none !important; } }";
}
return '<style>' . implode("\n", $cssRules) . '</style>';
}
private function generateHTML(array $data): string
{
$widget1 = $data['widget_1'] ?? [];
$widget1b = $data['widget_1b'] ?? [];
$widget2 = $data['widget_2'] ?? [];
$widget3 = $data['widget_3'] ?? [];
$newsletter = $data['newsletter'] ?? [];
$footerBottom = $data['footer_bottom'] ?? [];
$widget1Visible = $this->toBool($widget1['widget_1_visible'] ?? true);
$widget2Visible = $this->toBool($widget2['widget_2_visible'] ?? true);
$widget3Visible = $this->toBool($widget3['widget_3_visible'] ?? true);
$newsletterVisible = $this->toBool($newsletter['newsletter_visible'] ?? true);
$widget1Title = esc_html($widget1['widget_1_title'] ?? 'Recursos');
$widget1bTitle = esc_html($widget1b['widget_1b_title'] ?? 'Bases de datos');
$widget2Title = esc_html($widget2['widget_2_title'] ?? 'Soporte');
$widget3Title = esc_html($widget3['widget_3_title'] ?? 'Empresa');
$newsletterTitle = esc_html($newsletter['newsletter_title'] ?? 'Suscribete al Newsletter');
$newsletterDesc = esc_html($newsletter['newsletter_description'] ?? 'Recibe las ultimas actualizaciones.');
$newsletterNamePlaceholder = esc_attr($newsletter['newsletter_name_placeholder'] ?? 'Nombre');
$newsletterEmailPlaceholder = esc_attr($newsletter['newsletter_email_placeholder'] ?? 'Email');
$newsletterWhatsappPlaceholder = esc_attr($newsletter['newsletter_whatsapp_placeholder'] ?? 'WhatsApp');
$newsletterBtnText = esc_html($newsletter['newsletter_button_text'] ?? 'Suscribirse');
$copyrightText = esc_html($footerBottom['copyright_text'] ?? date('Y') . ' Todos los derechos reservados.');
$nonce = wp_create_nonce(self::NONCE_ACTION);
$ajaxUrl = admin_url('admin-ajax.php');
$html = '<footer class="roi-footer">';
$html .= '<div class="container">';
$html .= '<div class="footer-grid">';
// Columna 1: Widget 1 + Widget 1B
if ($widget1Visible) {
$html .= '<div class="footer-column footer-column-1">';
// Widget 1
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<span class="widget-title d-block h5">' . $widget1Title . '</span>';
$html .= $this->renderMenu('footer_menu_1');
$html .= '</div>';
// Widget 1B - Solo si tiene menu asignado
if (has_nav_menu('footer_menu_4')) {
$html .= '<div class="footer-widget footer-widget-menu footer-widget-1b">';
$html .= '<span class="widget-title d-block h5">' . $widget1bTitle . '</span>';
$html .= $this->renderMenu('footer_menu_4');
$html .= '</div>';
}
$html .= '</div>';
}
// Widget 2
if ($widget2Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<span class="widget-title d-block h5">' . $widget2Title . '</span>';
$html .= $this->renderMenu('footer_menu_2');
$html .= '</div>';
}
// Widget 3
if ($widget3Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<span class="widget-title d-block h5">' . $widget3Title . '</span>';
$html .= $this->renderMenu('footer_menu_3');
$html .= '</div>';
}
// Widget Newsletter
if ($newsletterVisible) {
$html .= '<div class="footer-widget footer-widget-newsletter">';
$html .= '<span class="widget-title d-block h5">' . $newsletterTitle . '</span>';
$html .= '<p class="newsletter-description">' . $newsletterDesc . '</p>';
$html .= '<form id="roi-newsletter-form" class="newsletter-form">';
$html .= '<input type="hidden" name="action" value="roi_newsletter_subscribe">';
$html .= '<input type="hidden" name="nonce" value="' . esc_attr($nonce) . '">';
$html .= '<input type="text" name="name" class="newsletter-input" placeholder="' . $newsletterNamePlaceholder . '">';
$html .= '<input type="email" name="email" class="newsletter-input" placeholder="' . $newsletterEmailPlaceholder . '" required>';
$html .= '<input type="tel" name="whatsapp" class="newsletter-input" placeholder="' . $newsletterWhatsappPlaceholder . '">';
$html .= '<button type="submit" class="newsletter-btn">' . $newsletterBtnText . '</button>';
$html .= '<div class="newsletter-message"></div>';
$html .= '</form>';
$html .= '</div>';
}
$html .= '</div>'; // .footer-grid
// Footer bottom
$html .= '<div class="footer-bottom">';
$html .= '<p class="copyright-text">&copy; ' . $copyrightText . '</p>';
$html .= '</div>';
$html .= '</div>'; // .container
$html .= '</footer>';
return $html;
}
private function renderMenu(string $menuLocation): string
{
if (!has_nav_menu($menuLocation)) {
return '<p class="text-muted">Menu no asignado</p>';
}
return wp_nav_menu([
'theme_location' => $menuLocation,
'container' => false,
'menu_class' => 'footer-nav',
'fallback_cb' => false,
'echo' => false,
'depth' => 1,
]) ?: '';
}
private function generateJS(array $data): string
{
$newsletter = $data['newsletter'] ?? [];
$successMsg = esc_js($newsletter['newsletter_success_message'] ?? 'Gracias por suscribirte!');
$errorMsg = esc_js($newsletter['newsletter_error_message'] ?? 'Error al suscribirse. Intenta de nuevo.');
$ajaxUrl = admin_url('admin-ajax.php');
$js = <<<JS
<script>
(function() {
const form = document.getElementById('roi-newsletter-form');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const btn = form.querySelector('.newsletter-btn');
const msgDiv = form.querySelector('.newsletter-message');
const emailInput = form.querySelector('input[name="email"]');
const originalText = btn.textContent;
// Reset message
msgDiv.style.display = 'none';
msgDiv.className = 'newsletter-message';
// Validate email
if (!emailInput.value || !emailInput.validity.valid) {
msgDiv.textContent = 'Por favor ingresa un email valido';
msgDiv.classList.add('error');
msgDiv.style.display = 'block';
return;
}
// Disable button
btn.disabled = true;
btn.textContent = 'Enviando...';
try {
const formData = new FormData(form);
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
const response = await fetch('{$ajaxUrl}', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
msgDiv.textContent = '{$successMsg}';
msgDiv.classList.add('success');
emailInput.value = '';
} else {
msgDiv.textContent = result.data?.message || '{$errorMsg}';
msgDiv.classList.add('error');
}
} catch (error) {
msgDiv.textContent = '{$errorMsg}';
msgDiv.classList.add('error');
}
msgDiv.style.display = 'block';
btn.disabled = false;
btn.textContent = originalText;
});
})();
</script>
JS;
return $js;
}
private function toBool($value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}