Migración completa a Clean Architecture con componentes funcionales

- Reorganización de estructura: Admin/, Public/, Shared/, Schemas/
- 12 componentes migrados: TopNotificationBar, Navbar, CtaLetsTalk, Hero,
  FeaturedImage, TableOfContents, CtaBoxSidebar, SocialShare, CtaPost,
  RelatedPost, ContactForm, Footer
- Panel de administración con tabs Bootstrap 5 funcionales
- Schemas JSON para configuración de componentes
- Renderers dinámicos con CSSGeneratorService (cero CSS hardcodeado)
- FormBuilders para UI admin con Design System consistente
- Fix: Bootstrap JS cargado en header para tabs funcionales
- Fix: buildTextInput maneja valores mixed (bool/string)
- Eliminación de estructura legacy (src/, admin/, assets/css/componente-*)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-25 21:20:06 -06:00
parent 90de6df77c
commit 0846a3bf03
224 changed files with 21670 additions and 17816 deletions

0
Public/.gitkeep Normal file
View File

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ContactForm\Infrastructure\Api\WordPress;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* ContactFormAjaxHandler - Procesa envios del formulario de contacto
*
* RESPONSABILIDAD: Recibir datos del formulario y enviarlos al webhook configurado
*
* SEGURIDAD:
* - Verifica nonce
* - Webhook URL NUNCA se expone al cliente
* - Webhook URL se obtiene de BD server-side
* - Rate limiting basico
* - Sanitizacion de inputs
*
* @package ROITheme\Public\ContactForm\Infrastructure\Api\WordPress
*/
final class ContactFormAjaxHandler
{
private const NONCE_ACTION = 'roi_contact_form_nonce';
private const COMPONENT_NAME = 'contact-form';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Registrar hooks AJAX
* Usa wp_ajax_nopriv para usuarios no logueados
*/
public function register(): void
{
add_action('wp_ajax_roi_contact_form_submit', [$this, 'handleSubmit']);
add_action('wp_ajax_nopriv_roi_contact_form_submit', [$this, 'handleSubmit']);
}
/**
* Procesar envio del formulario
*/
public function handleSubmit(): void
{
// 1. Verificar nonce
$nonce = sanitize_text_field($_POST['nonce'] ?? '');
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
wp_send_json_error([
'message' => __('Error de seguridad. Por favor recarga la pagina.', 'roi-theme')
], 403);
return;
}
// 2. Rate limiting basico (1 envio por IP cada 30 segundos)
if (!$this->checkRateLimit()) {
wp_send_json_error([
'message' => __('Por favor espera un momento antes de enviar otro mensaje.', 'roi-theme')
], 429);
return;
}
// 3. Sanitizar y validar inputs
$formData = $this->sanitizeFormData($_POST);
$validation = $this->validateFormData($formData);
if (!$validation['valid']) {
wp_send_json_error([
'message' => $validation['message'],
'errors' => $validation['errors']
], 422);
return;
}
// 4. Obtener configuracion del componente (incluye webhook URL)
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
wp_send_json_error([
'message' => __('Error de configuracion. Contacta al administrador.', 'roi-theme')
], 500);
return;
}
$integration = $settings['integration'] ?? [];
$webhookUrl = $integration['webhook_url'] ?? '';
$webhookMethod = $integration['webhook_method'] ?? 'POST';
$includePageUrl = $this->toBool($integration['include_page_url'] ?? true);
$includeTimestamp = $this->toBool($integration['include_timestamp'] ?? true);
if (empty($webhookUrl)) {
// Si no hay webhook configurado, simular exito para UX
// pero loguear warning para admin
error_log('ROI Theme Contact Form: No webhook URL configured');
wp_send_json_success([
'message' => $this->getSuccessMessage($settings)
]);
return;
}
// 5. Preparar payload para webhook
$payload = $this->preparePayload($formData, $includePageUrl, $includeTimestamp);
// 6. Enviar a webhook
$result = $this->sendToWebhook($webhookUrl, $webhookMethod, $payload);
if ($result['success']) {
wp_send_json_success([
'message' => $this->getSuccessMessage($settings)
]);
} else {
error_log('ROI Theme Contact Form webhook error: ' . $result['error']);
wp_send_json_error([
'message' => $this->getErrorMessage($settings)
], 500);
}
}
/**
* Sanitizar datos del formulario
*/
private function sanitizeFormData(array $post): array
{
return [
'fullName' => sanitize_text_field($post['fullName'] ?? ''),
'company' => sanitize_text_field($post['company'] ?? ''),
'whatsapp' => sanitize_text_field($post['whatsapp'] ?? ''),
'email' => sanitize_email($post['email'] ?? ''),
'message' => sanitize_textarea_field($post['message'] ?? ''),
];
}
/**
* Validar datos del formulario
*/
private function validateFormData(array $data): array
{
$errors = [];
// Nombre requerido
if (empty($data['fullName'])) {
$errors['fullName'] = __('El nombre es obligatorio', 'roi-theme');
}
// WhatsApp requerido
if (empty($data['whatsapp'])) {
$errors['whatsapp'] = __('El WhatsApp es obligatorio', 'roi-theme');
}
// Email requerido y valido
if (empty($data['email'])) {
$errors['email'] = __('El email es obligatorio', 'roi-theme');
} elseif (!is_email($data['email'])) {
$errors['email'] = __('Por favor ingresa un email valido', 'roi-theme');
}
if (!empty($errors)) {
return [
'valid' => false,
'message' => __('Por favor corrige los errores del formulario', 'roi-theme'),
'errors' => $errors
];
}
return ['valid' => true, 'message' => '', 'errors' => []];
}
/**
* Preparar payload para webhook
*/
private function preparePayload(array $formData, bool $includePageUrl, bool $includeTimestamp): array
{
$payload = [
'fullName' => $formData['fullName'],
'company' => $formData['company'],
'whatsapp' => $formData['whatsapp'],
'email' => $formData['email'],
'message' => $formData['message'],
];
if ($includePageUrl) {
$payload['pageUrl'] = sanitize_url($_POST['pageUrl'] ?? '');
$payload['pageTitle'] = sanitize_text_field($_POST['pageTitle'] ?? '');
}
if ($includeTimestamp) {
$payload['timestamp'] = current_time('c');
$payload['timezone'] = wp_timezone_string();
}
// Metadata adicional util para el webhook
$payload['source'] = 'contact-form';
$payload['siteName'] = get_bloginfo('name');
$payload['siteUrl'] = home_url();
return $payload;
}
/**
* Enviar datos al webhook
*/
private function sendToWebhook(string $url, string $method, array $payload): array
{
$args = [
'method' => strtoupper($method),
'timeout' => 30,
'redirection' => 5,
'httpversion' => '1.1',
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
];
if ($method === 'POST') {
$args['body'] = wp_json_encode($payload);
} else {
$url = add_query_arg($payload, $url);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return [
'success' => false,
'error' => $response->get_error_message()
];
}
$statusCode = wp_remote_retrieve_response_code($response);
// Considerar 2xx como exito
if ($statusCode >= 200 && $statusCode < 300) {
return ['success' => true, 'error' => ''];
}
return [
'success' => false,
'error' => sprintf('HTTP %d: %s', $statusCode, wp_remote_retrieve_response_message($response))
];
}
/**
* Rate limiting basico por IP
*/
private function checkRateLimit(): bool
{
$ip = $this->getClientIP();
$transientKey = 'roi_contact_form_' . md5($ip);
$lastSubmit = get_transient($transientKey);
if ($lastSubmit !== false) {
return false;
}
set_transient($transientKey, time(), 30);
return true;
}
/**
* Obtener IP del cliente
*/
private function getClientIP(): string
{
$ip = '';
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
return $ip;
}
/**
* Obtener mensaje de exito desde configuracion
*/
private function getSuccessMessage(array $data): string
{
$messages = $data['messages'] ?? [];
return $messages['success_message'] ?? __('¡Gracias por contactarnos! Te responderemos pronto.', 'roi-theme');
}
/**
* Obtener mensaje de error desde configuracion
*/
private function getErrorMessage(array $data): string
{
$messages = $data['messages'] ?? [];
return $messages['error_message'] ?? __('Hubo un error al enviar el mensaje. Por favor intenta de nuevo.', 'roi-theme');
}
/**
* Convertir valor a boolean
*/
private function toBool($value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}

View File

@@ -0,0 +1,461 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ContactForm\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* ContactFormRenderer - Renderiza formulario de contacto con webhook
*
* RESPONSABILIDAD: Generar HTML y CSS del formulario de contacto
*
* CARACTERISTICAS:
* - Formulario responsive Bootstrap 5
* - Envio a webhook configurable (no expuesto en frontend)
* - Info de contacto configurable
* - Mensajes de exito/error personalizables
*
* @package ROITheme\Public\ContactForm\Infrastructure\Ui
*/
final class ContactFormRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
$js = $this->buildJS($data);
return sprintf("<style>%s</style>\n%s\n<script>%s</script>", $css, $html, $js);
}
public function supports(string $componentType): bool
{
return $componentType === 'contact-form';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
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 generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$cssRules = [];
// Section background
$sectionBgColor = $colors['section_bg_color'] ?? 'rgba(108, 117, 125, 0.25)';
$sectionPaddingY = $spacing['section_padding_y'] ?? '3rem';
$sectionMarginTop = $spacing['section_margin_top'] ?? '3rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section', [
'background-color' => $sectionBgColor,
'padding-top' => $sectionPaddingY,
'padding-bottom' => $sectionPaddingY,
'margin-top' => $sectionMarginTop,
]);
// Title
$titleColor = $colors['title_color'] ?? '#212529';
$titleMarginBottom = $spacing['title_margin_bottom'] ?? '0.75rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .contact-title', [
'color' => $titleColor,
'margin-bottom' => $titleMarginBottom,
]);
// Description
$descColor = $colors['description_color'] ?? '#212529';
$descMarginBottom = $spacing['description_margin_bottom'] ?? '1.5rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .contact-description', [
'color' => $descColor,
'margin-bottom' => $descMarginBottom,
]);
// Icons
$iconColor = $colors['icon_color'] ?? '#FF8600';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .contact-icon', [
'color' => $iconColor,
]);
// Info labels and values
$infoLabelColor = $colors['info_label_color'] ?? '#212529';
$infoValueColor = $colors['info_value_color'] ?? '#6c757d';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .info-label', [
'color' => $infoLabelColor,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .info-value', [
'color' => $infoValueColor,
]);
// Form inputs
$inputBorderColor = $colors['input_border_color'] ?? '#dee2e6';
$inputFocusBorder = $colors['input_focus_border'] ?? '#FF8600';
$inputBorderRadius = $effects['input_border_radius'] ?? '6px';
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .form-control', [
'border-color' => $inputBorderColor,
'border-radius' => $inputBorderRadius,
'transition' => "all {$transitionDuration} ease",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .form-control:focus', [
'border-color' => $inputFocusBorder,
'box-shadow' => "0 0 0 0.2rem rgba(255, 134, 0, 0.25)",
'outline' => 'none',
]);
// Submit button
$buttonBgColor = $colors['button_bg_color'] ?? '#FF8600';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBg = $colors['button_hover_bg'] ?? '#e67a00';
$buttonBorderRadius = $effects['button_border_radius'] ?? '6px';
$buttonPadding = $effects['button_padding'] ?? '0.75rem 2rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .btn-contact-submit', [
'background-color' => $buttonBgColor,
'color' => $buttonTextColor,
'font-weight' => '600',
'padding' => $buttonPadding,
'border' => 'none',
'border-radius' => $buttonBorderRadius,
'transition' => "all {$transitionDuration} ease",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .btn-contact-submit:hover', [
'background-color' => $buttonHoverBg,
'color' => $buttonTextColor,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .btn-contact-submit:disabled', [
'opacity' => '0.7',
'cursor' => 'not-allowed',
]);
// Success/Error messages
$successBgColor = $colors['success_bg_color'] ?? '#d1e7dd';
$successTextColor = $colors['success_text_color'] ?? '#0f5132';
$errorBgColor = $colors['error_bg_color'] ?? '#f8d7da';
$errorTextColor = $colors['error_text_color'] ?? '#842029';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .alert-success', [
'background-color' => $successBgColor,
'color' => $successTextColor,
'border-color' => $successBgColor,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .alert-danger', [
'background-color' => $errorBgColor,
'color' => $errorTextColor,
'border-color' => $errorBgColor,
]);
return implode("\n", $cssRules);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$contactInfo = $data['contact_info'] ?? [];
$formLabels = $data['form_labels'] ?? [];
$effects = $data['visual_effects'] ?? [];
// Content
$sectionTitle = $content['section_title'] ?? '¿Tienes alguna pregunta?';
$sectionDesc = $content['section_description'] ?? 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.';
$submitText = $content['submit_button_text'] ?? 'Enviar Mensaje';
$submitIcon = $content['submit_button_icon'] ?? 'bi-send-fill';
// Contact info
$showContactInfo = $contactInfo['show_contact_info'] ?? true;
$showContactInfo = $showContactInfo === true || $showContactInfo === '1' || $showContactInfo === 1;
// Form labels/placeholders
$fullnamePlaceholder = $formLabels['fullname_placeholder'] ?? 'Nombre completo *';
$companyPlaceholder = $formLabels['company_placeholder'] ?? 'Empresa';
$whatsappPlaceholder = $formLabels['whatsapp_placeholder'] ?? 'WhatsApp *';
$emailPlaceholder = $formLabels['email_placeholder'] ?? 'Correo electrónico *';
$messagePlaceholder = $formLabels['message_placeholder'] ?? '¿En qué podemos ayudarte?';
$textareaRows = $effects['textarea_rows'] ?? '4';
// Container class
$containerClass = 'roi-contact-form-section';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
// Nonce for AJAX security
$nonce = wp_create_nonce('roi_contact_form_nonce');
$html = sprintf('<section class="%s">', esc_attr($containerClass));
$html .= '<div class="container">';
$html .= '<div class="row justify-content-center">';
$html .= '<div class="col-lg-10">';
$html .= '<div class="row">';
// Left column - Contact info
$html .= '<div class="col-lg-5 mb-4 mb-lg-0">';
$html .= sprintf('<h2 class="h3 contact-title">%s</h2>', esc_html($sectionTitle));
$html .= sprintf('<p class="contact-description">%s</p>', esc_html($sectionDesc));
if ($showContactInfo) {
$html .= $this->buildContactInfoHTML($contactInfo);
}
$html .= '</div>';
// Right column - Form
$html .= '<div class="col-lg-7">';
$html .= sprintf('<form id="roiContactForm" data-nonce="%s">', esc_attr($nonce));
$html .= '<div class="row g-3">';
// Full name field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="text" class="form-control" id="roiContactFullName" name="fullName" placeholder="%s" required>',
esc_attr($fullnamePlaceholder)
);
$html .= '</div>';
// Company field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="text" class="form-control" id="roiContactCompany" name="company" placeholder="%s">',
esc_attr($companyPlaceholder)
);
$html .= '</div>';
// WhatsApp field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="tel" class="form-control" id="roiContactWhatsapp" name="whatsapp" placeholder="%s" required>',
esc_attr($whatsappPlaceholder)
);
$html .= '</div>';
// Email field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="email" class="form-control" id="roiContactEmail" name="email" placeholder="%s" required>',
esc_attr($emailPlaceholder)
);
$html .= '</div>';
// Message field
$html .= '<div class="col-12">';
$html .= sprintf(
'<textarea class="form-control" id="roiContactMessage" name="message" rows="%s" placeholder="%s"></textarea>',
esc_attr($textareaRows),
esc_attr($messagePlaceholder)
);
$html .= '</div>';
// Submit button
$html .= '<div class="col-12">';
$html .= '<button type="submit" class="btn btn-contact-submit w-100">';
$html .= sprintf('<i class="%s me-2"></i>', esc_attr($submitIcon));
$html .= esc_html($submitText);
$html .= '</button>';
$html .= '</div>';
// Message container
$html .= '<div id="roiContactFormMessage" class="col-12 mt-2 alert" style="display: none;"></div>';
$html .= '</div>'; // .row g-3
$html .= '</form>';
$html .= '</div>'; // .col-lg-7
$html .= '</div>'; // .row
$html .= '</div>'; // .col-lg-10
$html .= '</div>'; // .row justify-content-center
$html .= '</div>'; // .container
$html .= '</section>';
return $html;
}
private function buildContactInfoHTML(array $contactInfo): string
{
$phoneLabel = $contactInfo['phone_label'] ?? 'Teléfono';
$phoneValue = $contactInfo['phone_value'] ?? '+52 55 1234 5678';
$emailLabel = $contactInfo['email_label'] ?? 'Email';
$emailValue = $contactInfo['email_value'] ?? 'contacto@apumexico.com';
$locationLabel = $contactInfo['location_label'] ?? 'Ubicación';
$locationValue = $contactInfo['location_value'] ?? 'Ciudad de México, México';
$html = '<div class="contact-info">';
// Phone
$html .= '<div class="d-flex align-items-start mb-3">';
$html .= '<i class="bi bi-telephone-fill me-3 fs-5 contact-icon"></i>';
$html .= '<div>';
$html .= sprintf('<h6 class="mb-1 info-label">%s</h6>', esc_html($phoneLabel));
$html .= sprintf('<p class="mb-0 info-value">%s</p>', esc_html($phoneValue));
$html .= '</div>';
$html .= '</div>';
// Email
$html .= '<div class="d-flex align-items-start mb-3">';
$html .= '<i class="bi bi-envelope-fill me-3 fs-5 contact-icon"></i>';
$html .= '<div>';
$html .= sprintf('<h6 class="mb-1 info-label">%s</h6>', esc_html($emailLabel));
$html .= sprintf('<p class="mb-0 info-value">%s</p>', esc_html($emailValue));
$html .= '</div>';
$html .= '</div>';
// Location
$html .= '<div class="d-flex align-items-start">';
$html .= '<i class="bi bi-geo-alt-fill me-3 fs-5 contact-icon"></i>';
$html .= '<div>';
$html .= sprintf('<h6 class="mb-1 info-label">%s</h6>', esc_html($locationLabel));
$html .= sprintf('<p class="mb-0 info-value">%s</p>', esc_html($locationValue));
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildJS(array $data): string
{
$messages = $data['messages'] ?? [];
$content = $data['content'] ?? [];
$successMessage = $messages['success_message'] ?? '¡Gracias por contactarnos! Te responderemos pronto.';
$errorMessage = $messages['error_message'] ?? 'Hubo un error al enviar el mensaje. Por favor intenta de nuevo.';
$sendingMessage = $messages['sending_message'] ?? 'Enviando...';
$submitText = $content['submit_button_text'] ?? 'Enviar Mensaje';
$submitIcon = $content['submit_button_icon'] ?? 'bi-send-fill';
// AJAX URL for WordPress
$ajaxUrl = admin_url('admin-ajax.php');
$js = <<<JS
(function() {
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('roiContactForm');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
const messageDiv = document.getElementById('roiContactFormMessage');
const originalBtnHtml = submitBtn.innerHTML;
const nonce = form.dataset.nonce;
// Disable button and show sending state
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
messageDiv.style.display = 'none';
// Collect form data
const formData = new FormData(form);
formData.append('action', 'roi_contact_form_submit');
formData.append('nonce', nonce);
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
try {
const response = await fetch('{$ajaxUrl}', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
messageDiv.className = 'col-12 mt-2 alert alert-success';
messageDiv.textContent = '{$successMessage}';
messageDiv.style.display = 'block';
form.reset();
} else {
messageDiv.className = 'col-12 mt-2 alert alert-danger';
messageDiv.textContent = result.data?.message || '{$errorMessage}';
messageDiv.style.display = 'block';
}
} catch (error) {
console.error('Contact form error:', error);
messageDiv.className = 'col-12 mt-2 alert alert-danger';
messageDiv.textContent = '{$errorMessage}';
messageDiv.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnHtml;
}
});
});
})();
JS;
return $js;
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar
*
* RESPONSABILIDAD: Generar HTML y CSS del CTA Box Sidebar
*
* CARACTERISTICAS:
* - Titulo configurable
* - Descripcion configurable
* - Boton con icono y multiples acciones (modal, link, scroll)
* - Estilos 100% desde BD via CSSGenerator
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar CTA box)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\CtaBoxSidebar\Infrastructure\Ui
*/
final class CtaBoxSidebarRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
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);
$script = $this->buildScript();
return sprintf("<style>%s</style>\n%s\n%s", $css, $html, $script);
}
public function supports(string $componentType): bool
{
return $componentType === 'cta-box-sidebar';
}
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 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$effects = $data['visual_effects'] ?? [];
$behavior = $data['behavior'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
// Container styles - Match template exactly (height: 250px, flexbox centering)
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar', [
'background' => $colors['background_color'] ?? '#FF8600',
'border-radius' => $effects['border_radius'] ?? '8px',
'padding' => $spacing['container_padding'] ?? '24px',
'text-align' => $behavior['text_align'] ?? 'center',
'box-shadow' => $effects['box_shadow'] ?? '0 4px 12px rgba(255, 133, 0, 0.2)',
'margin-top' => '0',
'margin-bottom' => '15px',
'height' => '250px',
'display' => 'flex',
'flex-direction' => 'column',
'justify-content' => 'center',
]);
// Title styles
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .cta-box-title', [
'color' => $colors['title_color'] ?? '#ffffff',
'font-weight' => $typography['title_font_weight'] ?? '700',
'font-size' => $typography['title_font_size'] ?? '1.25rem',
'margin-bottom' => $spacing['title_margin_bottom'] ?? '1rem',
'margin-top' => '0',
]);
// Description styles
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .cta-box-text', [
'color' => $colors['description_color'] ?? 'rgba(255, 255, 255, 0.95)',
'font-size' => $typography['description_font_size'] ?? '0.9rem',
'margin-bottom' => $spacing['description_margin_bottom'] ?? '1rem',
]);
// Button styles
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .btn-cta-box', [
'background-color' => $colors['button_background_color'] ?? '#ffffff',
'color' => $colors['button_text_color'] ?? '#FF8600',
'font-weight' => $typography['button_font_weight'] ?? '700',
'font-size' => $typography['button_font_size'] ?? '1rem',
'border' => 'none',
'padding' => $spacing['button_padding'] ?? '0.75rem 1.5rem',
'border-radius' => $effects['button_border_radius'] ?? '8px',
'transition' => "all {$transitionDuration} ease",
'cursor' => 'pointer',
'display' => 'inline-flex',
'align-items' => 'center',
'justify-content' => 'center',
'width' => '100%',
]);
// Button hover styles (template uses --color-navy-primary = #1e3a5f)
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .btn-cta-box:hover', [
'background-color' => $colors['button_hover_background'] ?? '#1e3a5f',
'color' => $colors['button_hover_text_color'] ?? '#ffffff',
]);
// Button icon spacing
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .btn-cta-box i', [
'margin-right' => $spacing['icon_margin_right'] ?? '0.5rem',
]);
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? false;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.cta-box-sidebar { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.cta-box-sidebar { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$title = $content['title'] ?? '¿Listo para potenciar tus proyectos?';
$description = $content['description'] ?? 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.';
$buttonText = $content['button_text'] ?? 'Solicitar Demo';
$buttonIcon = $content['button_icon'] ?? 'bi bi-calendar-check';
$buttonAction = $content['button_action'] ?? 'modal';
$buttonLink = $content['button_link'] ?? '#contactModal';
// Build button attributes based on action type
$buttonAttributes = $this->getButtonAttributes($buttonAction, $buttonLink);
$html = '<div class="cta-box-sidebar">';
// Title
$html .= sprintf(
'<h5 class="cta-box-title">%s</h5>',
esc_html($title)
);
// Description
$html .= sprintf(
'<p class="cta-box-text">%s</p>',
esc_html($description)
);
// Button
$iconHtml = !empty($buttonIcon)
? sprintf('<i class="%s"></i>', esc_attr($buttonIcon))
: '';
$html .= sprintf(
'<button class="btn btn-cta-box" %s>%s%s</button>',
$buttonAttributes,
$iconHtml,
esc_html($buttonText)
);
$html .= '</div>';
return $html;
}
private function getButtonAttributes(string $action, string $link): string
{
switch ($action) {
case 'modal':
// Extract modal ID from link (e.g., #contactModal -> contactModal)
$modalId = ltrim($link, '#');
return sprintf(
'type="button" data-bs-toggle="modal" data-bs-target="#%s"',
esc_attr($modalId)
);
case 'link':
return sprintf(
'type="button" data-cta-action="link" data-cta-href="%s"',
esc_url($link)
);
case 'scroll':
$targetId = ltrim($link, '#');
return sprintf(
'type="button" data-cta-action="scroll" data-cta-target="%s"',
esc_attr($targetId)
);
default:
return 'type="button"';
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
private function buildScript(): string
{
return <<<JS
<script>
document.addEventListener('DOMContentLoaded', function() {
var ctaButtons = document.querySelectorAll('.btn-cta-box[data-cta-action]');
ctaButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var action = this.getAttribute('data-cta-action');
if (action === 'link') {
var href = this.getAttribute('data-cta-href');
if (href) window.location.href = href;
} else if (action === 'scroll') {
var target = this.getAttribute('data-cta-target');
var el = document.getElementById(target);
if (el) el.scrollIntoView({behavior: 'smooth'});
}
});
});
});
</script>
JS;
}
}

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* Class CtaLetsTalkRenderer
*
* Renderizador del componente CTA "Let's Talk" para el frontend.
*
* Responsabilidades:
* - Renderizar botón CTA "Let's Talk" en el navbar
* - Delegar generación de CSS a CSSGeneratorInterface
* - Validar visibilidad (is_enabled, show_on_pages, show_on_desktop, show_on_mobile)
* - Manejar visibilidad responsive con clases Bootstrap
* - Generar atributos para modal o URL personalizada
* - 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\CtaLetsTalk\Infrastructure\Ui
*/
final class CtaLetsTalkRenderer 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 CSS usando CSSGeneratorService
$css = $this->generateCSS($data);
// Generar HTML
$html = $this->buildHTML($data);
// Combinar todo
return sprintf(
"<style>%s</style>\n%s",
$css,
$html
);
}
/**
* {@inheritDoc}
*/
public function supports(string $componentType): bool
{
return $componentType === 'cta-lets-talk';
}
/**
* 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(),
default => true,
};
}
/**
* Calcular clases de visibilidad responsive
*
* @param bool $desktop Mostrar en desktop
* @param bool $mobile Mostrar en mobile
* @return string|null Clases CSS o null si no debe mostrarse
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
/**
* Generar CSS usando CSSGeneratorService
*
* @param array $data Datos del componente
* @return string CSS generado
*/
private function generateCSS(array $data): string
{
$css = '';
// Estilos base del botón
$baseStyles = [
'background_color' => $data['colors']['background_color'] ?? '#FF8600',
'color' => $data['colors']['text_color'] ?? '#FFFFFF',
'font_size' => $data['typography']['font_size'] ?? '1rem',
'font_weight' => $data['typography']['font_weight'] ?? '600',
'text_transform' => $data['typography']['text_transform'] ?? 'none',
'padding' => sprintf(
'%s %s',
$data['spacing']['padding_top_bottom'] ?? '0.5rem',
$data['spacing']['padding_left_right'] ?? '1.5rem'
),
'border' => sprintf(
'%s solid %s',
$data['visual_effects']['border_width'] ?? '0',
$data['colors']['border_color'] ?? 'transparent'
),
'border_radius' => $data['visual_effects']['border_radius'] ?? '6px',
'box_shadow' => $data['visual_effects']['box_shadow'] ?? 'none',
'transition' => sprintf(
'all %s ease',
$data['visual_effects']['transition_duration'] ?? '0.3s'
),
'cursor' => 'pointer',
];
$css .= $this->cssGenerator->generate('.btn-lets-talk', $baseStyles);
// Estilos hover del botón
$hoverStyles = [
'background_color' => $data['colors']['background_hover_color'] ?? '#FF6B35',
'color' => $data['colors']['text_hover_color'] ?? '#FFFFFF',
];
$css .= "\n" . $this->cssGenerator->generate('.btn-lets-talk:hover', $hoverStyles);
// Estilos del ícono dentro del botón
$iconStyles = [
'color' => $data['colors']['text_color'] ?? '#FFFFFF',
'margin_right' => $data['spacing']['icon_spacing'] ?? '0.5rem',
];
$css .= "\n" . $this->cssGenerator->generate('.btn-lets-talk i', $iconStyles);
// Estilos responsive - ocultar en móvil si show_on_mobile = false
$showOnMobile = ($data['visibility']['show_on_mobile'] ?? false) === true;
if (!$showOnMobile) {
$responsiveStyles = [
'display' => 'none !important',
];
$css .= "\n@media (max-width: 991px) {\n";
$css .= $this->cssGenerator->generate('.btn-lets-talk', $responsiveStyles);
$css .= "\n}";
}
// Estilos responsive - ocultar en desktop si show_on_desktop = false
$showOnDesktop = ($data['visibility']['show_on_desktop'] ?? true) === true;
if (!$showOnDesktop) {
$responsiveStyles = [
'display' => 'none !important',
];
$css .= "\n@media (min-width: 992px) {\n";
$css .= $this->cssGenerator->generate('.btn-lets-talk', $responsiveStyles);
$css .= "\n}";
}
// Margen izquierdo para separar del menú (solo desktop)
$marginLeft = $data['spacing']['margin_left'] ?? '1rem';
if (!empty($marginLeft) && $marginLeft !== '0') {
$css .= "\n@media (min-width: 992px) {\n";
$css .= $this->cssGenerator->generate('.btn-lets-talk', ['margin_left' => $marginLeft]);
$css .= "\n}";
}
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);
$attributes = $this->buildAttributes($data);
$content = $this->buildContent($data);
$tag = $this->useModal($data) ? 'button' : 'a';
return sprintf(
'<%s class="%s"%s>%s</%s>',
$tag,
esc_attr($classes),
$attributes,
$content,
$tag
);
}
/**
* Construir clases CSS del componente
*
* @param array $data Datos del componente
* @return string Clases CSS
*/
private function buildClasses(array $data): string
{
$classes = ['btn', 'btn-lets-talk'];
// Agregar clase ms-lg-3 para margen en desktop (Bootstrap)
// Esto solo aplica en pantallas >= lg (992px)
$classes[] = 'ms-lg-3';
return implode(' ', $classes);
}
/**
* Determinar si debe usar modal o URL
*
* @param array $data Datos del componente
* @return bool
*/
private function useModal(array $data): bool
{
return ($data['behavior']['enable_modal'] ?? true) === true;
}
/**
* Construir atributos HTML del componente
*
* @param array $data Datos del componente
* @return string Atributos HTML
*/
private function buildAttributes(array $data): string
{
$attributes = [];
if ($this->useModal($data)) {
// Atributos para modal de Bootstrap
$attributes[] = 'type="button"';
$attributes[] = 'data-bs-toggle="modal"';
$modalTarget = $data['content']['modal_target'] ?? '#contactModal';
$attributes[] = sprintf('data-bs-target="%s"', esc_attr($modalTarget));
} else {
// Atributos para enlace
$customUrl = $data['behavior']['custom_url'] ?? '';
$attributes[] = sprintf('href="%s"', esc_url($customUrl ?: '#'));
if (($data['behavior']['open_in_new_tab'] ?? false) === true) {
$attributes[] = 'target="_blank"';
$attributes[] = 'rel="noopener noreferrer"';
}
}
// Atributo ARIA para accesibilidad
$ariaLabel = $data['content']['aria_label'] ?? 'Abrir formulario de contacto';
if (!empty($ariaLabel)) {
$attributes[] = sprintf('aria-label="%s"', esc_attr($ariaLabel));
}
return !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
}
/**
* Construir contenido del botón
*
* @param array $data Datos del componente
* @return string HTML del contenido
*/
private function buildContent(array $data): string
{
$html = '';
// Ícono (si está habilitado)
if ($this->shouldShowIcon($data)) {
$html .= $this->buildIcon($data);
}
// Texto del botón
$buttonText = $data['content']['button_text'] ?? "Let's Talk";
$html .= esc_html($buttonText);
return $html;
}
/**
* Verificar si debe mostrar el ícono
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowIcon(array $data): bool
{
return ($data['content']['show_icon'] ?? true) === true;
}
/**
* Construir ícono del componente
*
* @param array $data Datos del componente
* @return string HTML del ícono
*/
private function buildIcon(array $data): string
{
$iconClass = $data['content']['icon_class'] ?? 'bi-lightning-charge-fill';
// Asegurar prefijo 'bi-'
if (strpos($iconClass, 'bi-') !== 0) {
$iconClass = 'bi-' . $iconClass;
}
return sprintf(
'<i class="bi %s"></i>',
esc_attr($iconClass)
);
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CtaPost\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* CtaPostRenderer - Renderiza CTA promocional debajo del contenido
*
* RESPONSABILIDAD: Generar HTML y CSS del componente CTA Post
*
* CARACTERISTICAS:
* - Gradiente configurable
* - Layout responsive (2 columnas en desktop)
* - Boton CTA con icono
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\CtaPost\Infrastructure\Ui
*/
final class CtaPostRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
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);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'cta-post';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
$gradientStart = $colors['gradient_start'] ?? '#FF8600';
$gradientEnd = $colors['gradient_end'] ?? '#FFB800';
$gradientAngle = $effects['gradient_angle'] ?? '135deg';
$buttonBgColor = $colors['button_bg_color'] ?? '#FF8600';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBgColor = $colors['button_hover_bg_color'] ?? '#e67a00';
// Container - gradient background
$cssRules[] = $this->cssGenerator->generate('.cta-post-container', [
'background' => "linear-gradient({$gradientAngle}, {$gradientStart} 0%, {$gradientEnd} 100%)",
]);
// Button styles (matching template .cta-button) - Using !important to override Bootstrap btn-light
$cssRules[] = ".cta-post-container .cta-button {
background-color: {$buttonBgColor} !important;
color: {$buttonTextColor} !important;
font-weight: 600;
padding: 0.75rem 2rem;
border: none !important;
border-radius: 8px;
transition: 0.3s;
text-decoration: none;
display: inline-block;
}";
// Button hover state
$cssRules[] = ".cta-post-container .cta-button:hover {
background-color: {$buttonHoverBgColor};
color: {$buttonTextColor};
}";
// Responsive: button full width on mobile
$cssRules[] = "@media (max-width: 768px) {
.cta-post-container .cta-button {
width: 100%;
margin-top: 1rem;
}
}";
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnDesktop = $showOnDesktop === true || $showOnDesktop === '1' || $showOnDesktop === 1;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$showOnMobile = $showOnMobile === true || $showOnMobile === '1' || $showOnMobile === 1;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.cta-post-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.cta-post-container { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$title = $content['title'] ?? 'Accede a 200,000+ Análisis de Precios Unitarios';
$description = $content['description'] ?? '';
$buttonText = $content['button_text'] ?? 'Ver Catálogo Completo';
$buttonUrl = $content['button_url'] ?? '#';
$buttonIcon = $content['button_icon'] ?? 'bi-arrow-right';
$html = '<div class="my-5 p-4 rounded cta-post-container">';
$html .= ' <div class="row align-items-center">';
// Left column - Content
$html .= ' <div class="col-md-8">';
$html .= sprintf(
' <h3 class="h4 fw-bold text-white mb-2">%s</h3>',
esc_html($title)
);
if (!empty($description)) {
$html .= sprintf(
' <p class="text-white mb-md-0">%s</p>',
esc_html($description)
);
}
$html .= ' </div>';
// Right column - Button
$html .= ' <div class="col-md-4 text-md-end mt-3 mt-md-0">';
$html .= sprintf(
' <a href="%s" class="btn btn-light btn-lg cta-button">%s',
esc_url($buttonUrl),
esc_html($buttonText)
);
if (!empty($buttonIcon)) {
$html .= sprintf(' <i class="bi %s ms-2"></i>', esc_attr($buttonIcon));
}
$html .= '</a>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\FeaturedImage\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* FeaturedImageRenderer - Renderiza la imagen destacada del post
*
* RESPONSABILIDAD: Generar HTML y CSS de la imagen destacada
*
* CARACTERISTICAS:
* - Integracion con get_the_post_thumbnail()
* - Estilos configurables desde BD
* - Efecto hover opcional
* - Soporte responsive
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar featured image)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\FeaturedImage\Infrastructure\Ui
*/
final class FeaturedImageRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
if (!$this->hasPostThumbnail()) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'featured-image';
}
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 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function hasPostThumbnail(): bool
{
return is_singular() && has_post_thumbnail();
}
private function generateCSS(array $data): string
{
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$marginTop = $spacing['margin_top'] ?? '1rem';
$marginBottom = $spacing['margin_bottom'] ?? '2rem';
$borderRadius = $effects['border_radius'] ?? '12px';
$boxShadow = $effects['box_shadow'] ?? '0 8px 24px rgba(0, 0, 0, 0.1)';
$hoverEffect = $effects['hover_effect'] ?? true;
$hoverScale = $effects['hover_scale'] ?? '1.02';
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$cssRules = [];
// Container styles
$cssRules[] = $this->cssGenerator->generate('.featured-image-container', [
'border-radius' => $borderRadius,
'overflow' => 'hidden',
'box-shadow' => $boxShadow,
'margin-top' => $marginTop,
'margin-bottom' => $marginBottom,
'transition' => "transform {$transitionDuration} ease, box-shadow {$transitionDuration} ease",
]);
// Image styles
$cssRules[] = $this->cssGenerator->generate('.featured-image-container img', [
'width' => '100%',
'height' => 'auto',
'display' => 'block',
'transition' => "transform {$transitionDuration} ease",
]);
// Hover effect
if ($hoverEffect) {
$cssRules[] = $this->cssGenerator->generate('.featured-image-container:hover', [
'box-shadow' => '0 12px 32px rgba(0, 0, 0, 0.15)',
]);
$cssRules[] = $this->cssGenerator->generate('.featured-image-container:hover img', [
'transform' => "scale({$hoverScale})",
]);
}
// Link styles (remove default link styling)
$cssRules[] = $this->cssGenerator->generate('.featured-image-container a', [
'display' => 'block',
'line-height' => '0',
]);
// Responsive visibility
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 767.98px) {
.featured-image-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 768px) {
.featured-image-container { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$imageSize = $content['image_size'] ?? 'roi-featured-large';
$lazyLoading = $content['lazy_loading'] ?? true;
$linkToMedia = $content['link_to_media'] ?? false;
$imgAttr = [
'class' => 'img-fluid featured-image',
'alt' => get_the_title(),
];
if ($lazyLoading) {
$imgAttr['loading'] = 'lazy';
}
$thumbnail = get_the_post_thumbnail(null, $imageSize, $imgAttr);
if (empty($thumbnail)) {
return '';
}
$html = '<div class="featured-image-container">';
if ($linkToMedia) {
$fullImageUrl = get_the_post_thumbnail_url(null, 'full');
$html .= sprintf(
'<a href="%s" target="_blank" rel="noopener" aria-label="%s">',
esc_url($fullImageUrl),
esc_attr__('Ver imagen en tamano completo', 'roi-theme')
);
}
$html .= $thumbnail;
if ($linkToMedia) {
$html .= '</a>';
}
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\Footer\Infrastructure\Api\WordPress;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* NewsletterAjaxHandler - Procesa suscripciones al newsletter
*
* RESPONSABILIDAD: Recibir email y enviarlo al webhook configurado
*
* SEGURIDAD:
* - Verifica nonce
* - Webhook URL nunca se expone al cliente
* - Rate limiting basico
* - Sanitizacion de inputs
*
* @package ROITheme\Public\Footer\Infrastructure\Api\WordPress
*/
final class NewsletterAjaxHandler
{
private const NONCE_ACTION = 'roi_newsletter_nonce';
private const COMPONENT_NAME = 'footer';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Registrar hooks AJAX
*/
public function register(): void
{
add_action('wp_ajax_roi_newsletter_subscribe', [$this, 'handleSubscribe']);
add_action('wp_ajax_nopriv_roi_newsletter_subscribe', [$this, 'handleSubscribe']);
}
/**
* Procesar suscripcion
*/
public function handleSubscribe(): void
{
// 1. Verificar nonce
$nonce = sanitize_text_field($_POST['nonce'] ?? '');
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
wp_send_json_error([
'message' => __('Error de seguridad. Por favor recarga la pagina.', 'roi-theme')
], 403);
return;
}
// 2. Rate limiting (1 suscripcion por IP cada 60 segundos)
if (!$this->checkRateLimit()) {
wp_send_json_error([
'message' => __('Por favor espera un momento antes de intentar de nuevo.', 'roi-theme')
], 429);
return;
}
// 3. Validar email
$email = sanitize_email($_POST['email'] ?? '');
if (empty($email) || !is_email($email)) {
wp_send_json_error([
'message' => __('Por favor ingresa un email valido.', 'roi-theme')
], 422);
return;
}
// 4. Obtener configuracion del componente
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
wp_send_json_error([
'message' => __('Error de configuracion. Contacta al administrador.', 'roi-theme')
], 500);
return;
}
$newsletter = $settings['newsletter'] ?? [];
$webhookUrl = $newsletter['newsletter_webhook_url'] ?? '';
$successMsg = $newsletter['newsletter_success_message'] ?? __('Gracias por suscribirte!', 'roi-theme');
$errorMsg = $newsletter['newsletter_error_message'] ?? __('Error al suscribirse. Intenta de nuevo.', 'roi-theme');
if (empty($webhookUrl)) {
// Si no hay webhook, simular exito para UX pero loguear warning
error_log('ROI Theme Newsletter: No webhook URL configured');
wp_send_json_success([
'message' => $successMsg
]);
return;
}
// 5. Preparar payload
$payload = [
'email' => $email,
'source' => 'newsletter-footer',
'timestamp' => current_time('c'),
'siteName' => get_bloginfo('name'),
'siteUrl' => home_url(),
];
// 6. Enviar a webhook
$result = $this->sendToWebhook($webhookUrl, $payload);
if ($result['success']) {
wp_send_json_success([
'message' => $successMsg
]);
} else {
error_log('ROI Theme Newsletter webhook error: ' . $result['error']);
wp_send_json_error([
'message' => $errorMsg
], 500);
}
}
/**
* Enviar datos al webhook
*/
private function sendToWebhook(string $url, array $payload): array
{
$response = wp_remote_post($url, [
'timeout' => 30,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
return [
'success' => false,
'error' => $response->get_error_message()
];
}
$statusCode = wp_remote_retrieve_response_code($response);
if ($statusCode >= 200 && $statusCode < 300) {
return ['success' => true, 'error' => ''];
}
return [
'success' => false,
'error' => sprintf('HTTP %d: %s', $statusCode, wp_remote_retrieve_response_message($response))
];
}
/**
* Rate limiting por IP
*/
private function checkRateLimit(): bool
{
$ip = $this->getClientIP();
$transientKey = 'roi_newsletter_' . md5($ip);
$lastSubmit = get_transient($transientKey);
if ($lastSubmit !== false) {
return false;
}
set_transient($transientKey, time(), 60);
return true;
}
/**
* Obtener IP del cliente
*/
private function getClientIP(): string
{
$ip = '';
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
return $ip;
}
}

View File

@@ -0,0 +1,423 @@
<?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,
]);
// 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'] ?? [];
$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');
$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.');
$newsletterPlaceholder = esc_attr($newsletter['newsletter_placeholder'] ?? 'Email');
$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">';
// Widget 1
if ($widget1Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<h5 class="widget-title">' . $widget1Title . '</h5>';
$html .= $this->renderMenu('footer_menu_1');
$html .= '</div>';
}
// Widget 2
if ($widget2Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<h5 class="widget-title">' . $widget2Title . '</h5>';
$html .= $this->renderMenu('footer_menu_2');
$html .= '</div>';
}
// Widget 3
if ($widget3Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<h5 class="widget-title">' . $widget3Title . '</h5>';
$html .= $this->renderMenu('footer_menu_3');
$html .= '</div>';
}
// Widget Newsletter
if ($newsletterVisible) {
$html .= '<div class="footer-widget footer-widget-newsletter">';
$html .= '<h5 class="widget-title">' . $newsletterTitle . '</h5>';
$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="email" name="email" class="newsletter-input" placeholder="' . $newsletterPlaceholder . '" required>';
$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);
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;
}
}

View File

@@ -0,0 +1,278 @@
<?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\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
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
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);
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';
$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,
]);
$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',
]);
$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',
]);
$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';
}
}

View File

@@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\herosection\infrastructure\ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
/**
* HeroSectionRenderer - Renderiza la sección hero con badges y título
*
* RESPONSABILIDAD: Generar HTML de la sección hero
*
* CARACTERÍSTICAS:
* - Badges de categorías con múltiples fuentes de datos
* - Título H1 con gradiente opcional
* - Múltiples tipos de fondo (color, gradiente, imagen)
* - Lógica condicional de visibilidad por tipo de página
*
* @package ROITheme\Public\HeroSection\Presentation
*/
final class HeroSectionRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$classes = $this->buildSectionClasses($data);
$styles = $this->buildInlineStyles($data);
$html = sprintf(
'<div class="%s"%s>',
esc_attr($classes),
$styles ? ' style="' . esc_attr($styles) . '"' : ''
);
$html .= '<div class="container">';
// Categories badges
if ($this->shouldShowCategories($data)) {
$html .= $this->buildCategoriesBadges($data);
}
// Title
$html .= $this->buildTitle($data);
$html .= '</div>';
$html .= '</div>';
// Custom styles
$html .= $this->buildCustomStyles($data);
return $html;
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === 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();
case 'posts':
return is_single() && get_post_type() === 'post';
case 'pages':
return is_page();
case 'custom':
$postTypes = $data['visibility']['custom_post_types'] ?? '';
$allowedTypes = array_map('trim', explode(',', $postTypes));
return in_array(get_post_type(), $allowedTypes, true);
default:
return true;
}
}
private function shouldShowCategories(array $data): bool
{
return isset($data['categories']['show_categories']) &&
$data['categories']['show_categories'] === true;
}
private function buildSectionClasses(array $data): string
{
$classes = ['container-fluid', 'hero-title'];
$paddingClass = $this->getPaddingClass($data['styles']['padding_vertical'] ?? 'normal');
$classes[] = $paddingClass;
$marginClass = $this->getMarginClass($data['styles']['margin_bottom'] ?? 'normal');
if ($marginClass) {
$classes[] = $marginClass;
}
return implode(' ', $classes);
}
private function getPaddingClass(string $padding): string
{
$paddings = [
'compact' => 'py-3',
'normal' => 'py-5',
'spacious' => 'py-6',
'extra-spacious' => 'py-7'
];
return $paddings[$padding] ?? 'py-5';
}
private function getMarginClass(string $margin): string
{
$margins = [
'none' => '',
'small' => 'mb-2',
'normal' => 'mb-4',
'large' => 'mb-5'
];
return $margins[$margin] ?? 'mb-4';
}
private function buildInlineStyles(array $data): string
{
$styles = [];
$backgroundType = $data['styles']['background_type'] ?? 'gradient';
switch ($backgroundType) {
case 'color':
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
$styles[] = "background-color: {$bgColor}";
break;
case 'gradient':
$startColor = $data['styles']['gradient_start_color'] ?? '#1e3a5f';
$endColor = $data['styles']['gradient_end_color'] ?? '#2c5282';
$angle = $data['styles']['gradient_angle'] ?? 135;
$styles[] = "background: linear-gradient({$angle}deg, {$startColor}, {$endColor})";
break;
case 'image':
$imageUrl = $data['styles']['background_image_url'] ?? '';
if (!empty($imageUrl)) {
$styles[] = "background-image: url('" . esc_url($imageUrl) . "')";
$styles[] = "background-size: cover";
$styles[] = "background-position: center";
$styles[] = "background-repeat: no-repeat";
if (isset($data['styles']['background_overlay']) && $data['styles']['background_overlay']) {
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
$styles[] = "position: relative";
}
}
break;
}
// Text color
if (!empty($data['styles']['text_color'])) {
$styles[] = 'color: ' . $data['styles']['text_color'];
}
return implode('; ', $styles);
}
private function buildCategoriesBadges(array $data): string
{
$categories = $this->getCategories($data);
if (empty($categories)) {
return '';
}
$maxCategories = $data['categories']['max_categories'] ?? 5;
$categories = array_slice($categories, 0, $maxCategories);
$alignment = $data['categories']['categories_alignment'] ?? 'center';
$alignmentClasses = [
'left' => 'justify-content-start',
'center' => 'justify-content-center',
'right' => 'justify-content-end'
];
$alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center';
$icon = $data['categories']['category_icon'] ?? 'bi-folder-fill';
if (strpos($icon, 'bi-') !== 0) {
$icon = 'bi-' . $icon;
}
$html = sprintf('<div class="mb-3 d-flex %s">', esc_attr($alignmentClass));
$html .= '<div class="d-flex gap-2 flex-wrap justify-content-center">';
foreach ($categories as $category) {
$html .= sprintf(
'<a href="%s" class="category-badge category-badge-hero"><i class="bi %s me-1"></i>%s</a>',
esc_url($category['url']),
esc_attr($icon),
esc_html($category['name'])
);
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function getCategories(array $data): array
{
$source = $data['categories']['categories_source'] ?? 'post_categories';
switch ($source) {
case 'post_categories':
return $this->getPostCategories();
case 'post_tags':
return $this->getPostTags();
case 'custom_taxonomy':
$taxonomy = $data['categories']['custom_taxonomy_name'] ?? '';
return $this->getCustomTaxonomyTerms($taxonomy);
case 'custom_list':
$list = $data['categories']['custom_categories_list'] ?? '';
return $this->parseCustomCategoriesList($list);
default:
return [];
}
}
private function getPostCategories(): array
{
$categories = get_the_category();
if (empty($categories)) {
return [];
}
$result = [];
foreach ($categories as $category) {
$result[] = [
'name' => $category->name,
'url' => get_category_link($category->term_id)
];
}
return $result;
}
private function getPostTags(): array
{
$tags = get_the_tags();
if (empty($tags)) {
return [];
}
$result = [];
foreach ($tags as $tag) {
$result[] = [
'name' => $tag->name,
'url' => get_tag_link($tag->term_id)
];
}
return $result;
}
private function getCustomTaxonomyTerms(string $taxonomy): array
{
if (empty($taxonomy)) {
return [];
}
$terms = get_the_terms(get_the_ID(), $taxonomy);
if (empty($terms) || is_wp_error($terms)) {
return [];
}
$result = [];
foreach ($terms as $term) {
$result[] = [
'name' => $term->name,
'url' => get_term_link($term)
];
}
return $result;
}
private function parseCustomCategoriesList(string $list): array
{
if (empty($list)) {
return [];
}
$lines = explode("\n", $list);
$result = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
$parts = explode('|', $line);
if (count($parts) >= 2) {
$result[] = [
'name' => trim($parts[0]),
'url' => trim($parts[1])
];
}
}
return $result;
}
private function buildTitle(array $data): string
{
$titleText = $this->getTitleText($data);
if (empty($titleText)) {
return '';
}
$titleTag = $data['title']['title_tag'] ?? 'h1';
$titleClasses = $data['title']['title_classes'] ?? 'display-5 fw-bold';
$alignment = $data['title']['title_alignment'] ?? 'center';
$alignmentClasses = [
'left' => 'text-start',
'center' => 'text-center',
'right' => 'text-end'
];
$alignmentClass = $alignmentClasses[$alignment] ?? 'text-center';
$classes = trim($titleClasses . ' ' . $alignmentClass);
$titleStyle = '';
if (isset($data['title']['enable_gradient']) && $data['title']['enable_gradient']) {
$titleStyle = $this->buildGradientStyle($data);
$classes .= ' roi-gradient-text';
}
return sprintf(
'<%s class="%s"%s>%s</%s>',
esc_attr($titleTag),
esc_attr($classes),
$titleStyle ? ' style="' . esc_attr($titleStyle) . '"' : '',
esc_html($titleText),
esc_attr($titleTag)
);
}
private function getTitleText(array $data): string
{
$source = $data['title']['title_source'] ?? 'post_title';
switch ($source) {
case 'post_title':
return get_the_title();
case 'custom_field':
$fieldName = $data['title']['custom_field_name'] ?? '';
if (!empty($fieldName)) {
$value = get_post_meta(get_the_ID(), $fieldName, true);
return is_string($value) ? $value : '';
}
return '';
case 'custom_text':
return $data['title']['custom_text'] ?? '';
default:
return get_the_title();
}
}
private function buildGradientStyle(array $data): string
{
$startColor = $data['title']['gradient_color_start'] ?? '#1e3a5f';
$endColor = $data['title']['gradient_color_end'] ?? '#FF8600';
$direction = $data['title']['gradient_direction'] ?? 'to-right';
$directions = [
'to-right' => 'to right',
'to-left' => 'to left',
'to-bottom' => 'to bottom',
'to-top' => 'to top',
'diagonal' => '135deg'
];
$gradientDirection = $directions[$direction] ?? 'to right';
return "background: linear-gradient({$gradientDirection}, {$startColor}, {$endColor}); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;";
}
private function buildCustomStyles(array $data): string
{
$badgeBg = $data['styles']['category_badge_background'] ?? 'rgba(255, 255, 255, 0.2)';
$badgeTextColor = $data['styles']['category_badge_text_color'] ?? '#FFFFFF';
$badgeBlur = isset($data['styles']['category_badge_blur']) && $data['styles']['category_badge_blur'];
$blurStyle = $badgeBlur ? 'backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);' : '';
$overlayStyle = '';
if (($data['styles']['background_type'] ?? '') === 'image' &&
isset($data['styles']['background_overlay']) &&
$data['styles']['background_overlay']) {
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
$overlayStyle = <<<CSS
.hero-title::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, {$opacity});
z-index: 0;
}
.hero-title > .container {
position: relative;
z-index: 1;
}
CSS;
}
return <<<STYLES
<style>
.category-badge-hero {
background-color: {$badgeBg};
color: {$badgeTextColor};
padding: 0.5rem 1rem;
border-radius: 2rem;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
display: inline-flex;
align-items: center;
transition: all 0.3s ease;
{$blurStyle}
}
.category-badge-hero:hover {
background-color: rgba(255, 134, 0, 0.3);
color: {$badgeTextColor};
transform: translateY(-2px);
}
.roi-gradient-text {
display: inline-block;
}
{$overlayStyle}
</style>
STYLES;
}
public function supports(string $componentType): bool
{
return $componentType === 'hero-section';
}
}

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\Navbar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use Walker_Nav_Menu;
/**
* NavbarRenderer - Renderiza el menú de navegación principal
*
* RESPONSABILIDAD: Generar HTML del menú de navegación WordPress
*
* CARACTERÍSTICAS:
* - Integración con wp_nav_menu()
* - Walker personalizado para Bootstrap 5
* - Soporte para submenús desplegables
* - Responsive con navbar-toggler
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar navbar)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\Navbar\Infrastructure\Ui
*/
final class NavbarRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildMenu($data);
return sprintf(
"<style>%s</style>\n%s",
$css,
$html
);
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function shouldShowOnMobile(array $data): bool
{
return isset($data['visibility']['show_on_mobile']) &&
$data['visibility']['show_on_mobile'] === true;
}
/**
* Generar CSS usando CSSGeneratorService
*
* @param array $data Datos del componente
* @return string CSS generado
*/
private function generateCSS(array $data): string
{
$css = '';
// Obtener valores de configuración
$stickyEnabled = $data['visibility']['sticky_enabled'] ?? true;
$paddingVertical = $data['layout']['padding_vertical'] ?? '0.75rem 0';
$zIndex = $data['layout']['z_index'] ?? '1030';
$bgColor = $data['colors']['background_color'] ?? '#1e3a5f';
$boxShadow = $data['colors']['box_shadow'] ?? '0 4px 12px rgba(30, 58, 95, 0.15)';
$linkTextColor = $data['links']['text_color'] ?? '#FFFFFF';
$linkHoverColor = $data['links']['hover_color'] ?? '#FF8600';
$linkActiveColor = $data['links']['active_color'] ?? '#FF8600';
$linkFontSize = $data['links']['font_size'] ?? '0.9rem';
$linkFontWeight = $data['links']['font_weight'] ?? '500';
$linkPadding = $data['links']['padding'] ?? '0.5rem 0.65rem';
$linkBorderRadius = $data['links']['border_radius'] ?? '4px';
$showUnderlineEffect = $data['links']['show_underline_effect'] ?? true;
$underlineColor = $data['links']['underline_color'] ?? '#FF8600';
// Estilos del navbar container
$navbarStyles = [
'background-color' => $bgColor . ' !important',
'box-shadow' => $boxShadow,
'padding' => $paddingVertical,
'transition' => 'all 0.3s ease',
];
if ($stickyEnabled) {
$navbarStyles['position'] = 'sticky';
$navbarStyles['top'] = '0';
$navbarStyles['z-index'] = $zIndex;
}
$css .= $this->cssGenerator->generate('.navbar', $navbarStyles);
// Efecto scrolled del navbar
$css .= "\n" . $this->cssGenerator->generate('.navbar.scrolled', [
'box-shadow' => '0 6px 20px rgba(30, 58, 95, 0.25)',
]);
// Estilos de los enlaces del navbar
$navLinkStyles = [
'color' => 'rgba(255, 255, 255, 0.9) !important',
'font-weight' => $linkFontWeight,
'position' => 'relative',
'padding' => $linkPadding . ' !important',
'transition' => 'all 0.3s ease',
'font-size' => $linkFontSize,
'white-space' => 'nowrap',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .nav-link', $navLinkStyles);
// Efecto de subrayado (::after pseudo-element)
if ($showUnderlineEffect) {
$css .= "\n.navbar .nav-link::after {";
$css .= "\n content: '';";
$css .= "\n position: absolute;";
$css .= "\n bottom: 0;";
$css .= "\n left: 50%;";
$css .= "\n transform: translateX(-50%) scaleX(0);";
$css .= "\n width: 80%;";
$css .= "\n height: 2px;";
$css .= "\n background: {$underlineColor};";
$css .= "\n transition: transform 0.3s ease;";
$css .= "\n}";
$css .= "\n.navbar .nav-link:hover::after {";
$css .= "\n transform: translateX(-50%) scaleX(1);";
$css .= "\n}";
}
// Estilos hover y focus de los enlaces
$navLinkHoverStyles = [
'color' => $linkHoverColor . ' !important',
'background-color' => 'rgba(255, 133, 0, 0.1)',
'border-radius' => $linkBorderRadius,
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .nav-link:hover, .navbar .nav-link:focus', $navLinkHoverStyles);
// Estilos de enlaces activos
$navLinkActiveStyles = [
'color' => $linkActiveColor . ' !important',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .nav-link.active, .navbar .nav-item.current-menu-item > .nav-link', $navLinkActiveStyles);
// Estilos del dropdown menu
$dropdownMaxHeight = $data['visual_effects']['dropdown_max_height'] ?? '300px';
$dropdownStyles = [
'background' => $data['visual_effects']['background_color'] ?? '#ffffff',
'border' => 'none',
'box-shadow' => $data['visual_effects']['shadow'] ?? '0 8px 24px rgba(0, 0, 0, 0.12)',
'border-radius' => $data['visual_effects']['border_radius'] ?? '8px',
'padding' => '0.5rem 0',
'max-height' => $dropdownMaxHeight,
'overflow-y' => 'auto',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .dropdown-menu', $dropdownStyles);
// Hover en desktop para mostrar dropdown (sin necesidad de clic)
$css .= "\n@media (min-width: 992px) {";
$css .= "\n .navbar .dropdown:hover > .dropdown-menu {";
$css .= "\n display: block;";
$css .= "\n margin-top: 0;";
$css .= "\n }";
$css .= "\n .navbar .dropdown > .dropdown-toggle:active {";
$css .= "\n pointer-events: none;";
$css .= "\n }";
$css .= "\n}";
// Estilos de items del dropdown
$dropdownItemStyles = [
'color' => $data['visual_effects']['item_color'] ?? '#495057',
'padding' => $data['visual_effects']['item_padding'] ?? '0.625rem 1.25rem',
'transition' => 'all 0.3s ease',
'font-weight' => '500',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .dropdown-item', $dropdownItemStyles);
// Estilos hover de items del dropdown
$dropdownItemHoverStyles = [
'background-color' => $data['visual_effects']['item_hover_background'] ?? 'rgba(255, 133, 0, 0.1)',
'color' => $linkHoverColor,
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .dropdown-item:hover, .navbar .dropdown-item:focus', $dropdownItemHoverStyles);
// Estilos del brand (texto)
$brandStyles = [
'color' => ($data['media']['brand_color'] ?? '#FFFFFF') . ' !important',
'font-weight' => '700',
'font-size' => $data['media']['brand_font_size'] ?? '1.5rem',
'transition' => 'color 0.3s ease',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .navbar-brand, .navbar .roi-navbar-brand', $brandStyles);
// Estilos hover del brand
$brandHoverStyles = [
'color' => ($data['media']['brand_hover_color'] ?? '#FF8600') . ' !important',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .navbar-brand:hover, .navbar .roi-navbar-brand:hover', $brandHoverStyles);
// Estilos del logo (imagen)
$logoStyles = [
'height' => $data['media']['logo_height'] ?? '40px',
'width' => 'auto',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .roi-navbar-logo', $logoStyles);
return $css;
}
private function buildMenu(array $data): string
{
$menuLocation = $data['behavior']['menu_location'] ?? 'primary';
$enableDropdowns = $data['behavior']['enable_dropdowns'] ?? true;
$mobileBreakpoint = $data['behavior']['mobile_breakpoint'] ?? 'lg';
$ulClass = 'navbar-nav mb-2 mb-lg-0';
$args = [
'theme_location' => $menuLocation === 'custom' ? '' : $menuLocation,
'menu' => $menuLocation === 'custom' ? ($data['behavior']['custom_menu_id'] ?? 0) : '',
'container' => false,
'menu_class' => $ulClass,
'fallback_cb' => '__return_false',
'items_wrap' => '<ul id="%1$s" class="%2$s">%3$s</ul>',
'depth' => $enableDropdowns ? 2 : 1,
'walker' => new ROI_Bootstrap_Nav_Walker()
];
ob_start();
wp_nav_menu($args);
return ob_get_clean();
}
/**
* Obtiene las clases CSS de Bootstrap para visibilidad responsive
*
* Implementa tabla de decisión según especificación:
* - 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
{
if ($desktop && $mobile) {
return null; // Sin clases = visible siempre
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
return 'd-none';
}
public function supports(string $componentType): bool
{
return $componentType === 'navbar';
}
}
/**
* Custom Walker for Bootstrap 5 Navigation
*
* RESPONSABILIDAD: Adaptar wp_nav_menu() a Bootstrap 5
*
* CARACTERÍSTICAS:
* - Clases Bootstrap 5 (.nav-item, .nav-link, .dropdown)
* - Atributos data-bs-toggle para dropdowns
* - Soporte para current-menu-item
*/
class ROI_Bootstrap_Nav_Walker extends Walker_Nav_Menu
{
public function start_lvl(&$output, $depth = 0, $args = null)
{
$indent = str_repeat("\t", $depth);
$output .= "\n$indent<ul class=\"dropdown-menu\">\n";
}
public function start_el(&$output, $item, $depth = 0, $args = null, $id = 0)
{
$indent = ($depth) ? str_repeat("\t", $depth) : '';
$classes = empty($item->classes) ? [] : (array) $item->classes;
$classes[] = 'nav-item';
if ($args->walker->has_children) {
$classes[] = 'dropdown';
}
$class_names = join(' ', apply_filters('nav_menu_css_class', array_filter($classes), $item, $args, $depth));
$class_names = $class_names ? ' class="' . esc_attr($class_names) . '"' : '';
$id = apply_filters('nav_menu_item_id', 'menu-item-' . $item->ID, $item, $args, $depth);
$id = $id ? ' id="' . esc_attr($id) . '"' : '';
$output .= $indent . '<li' . $id . $class_names . '>';
$atts = [];
$atts['title'] = !empty($item->attr_title) ? $item->attr_title : '';
$atts['target'] = !empty($item->target) ? $item->target : '';
$atts['rel'] = !empty($item->xfn) ? $item->xfn : '';
$atts['href'] = !empty($item->url) ? $item->url : '';
if ($depth === 0) {
$atts['class'] = 'nav-link';
if ($args->walker->has_children) {
$atts['class'] .= ' dropdown-toggle';
$atts['data-bs-toggle'] = 'dropdown';
$atts['role'] = 'button';
$atts['aria-expanded'] = 'false';
}
} else {
$atts['class'] = 'dropdown-item';
}
if (in_array('current-menu-item', $classes)) {
$atts['class'] .= ' active';
}
$atts = apply_filters('nav_menu_link_attributes', $atts, $item, $args, $depth);
$attributes = '';
foreach ($atts as $attr => $value) {
if (!empty($value)) {
$value = ('href' === $attr) ? esc_url($value) : esc_attr($value);
$attributes .= ' ' . $attr . '="' . $value . '"';
}
}
$title = apply_filters('the_title', $item->title, $item->ID);
$title = apply_filters('nav_menu_item_title', $title, $item, $args, $depth);
$item_output = $args->before;
$item_output .= '<a' . $attributes . '>';
$item_output .= $args->link_before . $title . $args->link_after;
$item_output .= '</a>';
$item_output .= $args->after;
$output .= apply_filters('walker_nav_menu_start_el', $item_output, $item, $depth, $args);
}
}

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\RelatedPost\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* RelatedPostRenderer - Renderiza seccion de posts relacionados
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Related Posts
*
* CARACTERISTICAS:
* - Grid responsive de cards
* - Query dinamica de posts
* - Paginacion Bootstrap
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\RelatedPost\Infrastructure\Ui
*/
final class RelatedPostRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
$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 === 'related-post';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
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 generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$typography = $data['typography'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
// Variables de colores del tema (defaults del template)
$colorNavyPrimary = $colors['section_title_color'] ?? '#0E2337';
$colorOrangePrimary = $colors['card_hover_border_color'] ?? '#FF8600';
$colorNeutral50 = '#f9fafb';
$colorNeutral100 = '#e5e7eb';
$colorNeutral600 = $colors['card_border_color'] ?? '#6b7280';
// Container - margin 3rem 0
$cssRules[] = $this->cssGenerator->generate('.related-posts', [
'margin' => '3rem 0',
]);
// Section title - color navy, font-weight 700, margin-bottom 2rem
$cssRules[] = $this->cssGenerator->generate('.related-posts h2', [
'color' => $colorNavyPrimary,
'font-weight' => '700',
'margin-bottom' => '2rem',
]);
// Card styles - cursor pointer, border, border-left 4px
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? $colorNeutral50;
$cssRules[] = ".related-posts .card {
cursor: pointer;
background: {$cardBgColor} !important;
border: 1px solid {$colorNeutral100} !important;
border-left: 4px solid {$colorNeutral600} !important;
transition: all 0.3s ease;
height: 100%;
}";
// Card hover - background change, shadow, border-left orange
$cssRules[] = ".related-posts .card:hover {
background: {$cardHoverBgColor} !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border-left-color: {$colorOrangePrimary} !important;
}";
// Card body - padding 1.5rem
$cssRules[] = $this->cssGenerator->generate('.related-posts .card-body', [
'padding' => '1.5rem !important',
]);
// Card title - color navy, font-weight 600, font-size 0.95rem
$cardTitleColor = $colors['card_title_color'] ?? $colorNavyPrimary;
$cssRules[] = ".related-posts .card-title {
color: {$cardTitleColor} !important;
font-weight: 600;
font-size: 0.95rem;
line-height: 1.4;
}";
// Link hover - title changes to orange
$cssRules[] = ".related-posts a:hover .card-title {
color: {$colorOrangePrimary} !important;
}";
// Pagination styles - matching template exactly
$cssRules[] = ".related-posts .page-link {
color: {$colorNeutral600};
border: 1px solid {$colorNeutral100};
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
}";
$cssRules[] = ".related-posts .page-link:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$colorOrangePrimary};
color: {$colorOrangePrimary};
}";
$cssRules[] = ".related-posts .page-item.active .page-link {
background-color: {$colorOrangePrimary};
border-color: {$colorOrangePrimary};
color: #ffffff;
}";
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnDesktop = $showOnDesktop === true || $showOnDesktop === '1' || $showOnDesktop === 1;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$showOnMobile = $showOnMobile === true || $showOnMobile === '1' || $showOnMobile === 1;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.related-posts { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.related-posts { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$layout = $data['layout'] ?? [];
$sectionTitle = $content['section_title'] ?? 'Descubre Mas Contenido';
$postsPerPage = (int)($content['posts_per_page'] ?? 12);
$orderby = $content['orderby'] ?? 'rand';
$order = $content['order'] ?? 'DESC';
$showPagination = $content['show_pagination'] ?? true;
$showPagination = $showPagination === true || $showPagination === '1' || $showPagination === 1;
// Layout columns (cast to string to handle boolean conversion from DB)
$colsDesktop = (string)($layout['columns_desktop'] ?? '3');
$colsTablet = (string)($layout['columns_tablet'] ?? '2');
$colsMobile = (string)($layout['columns_mobile'] ?? '1');
// Handle '1' stored as boolean true in DB
if ($colsDesktop === '1' || $colsDesktop === '') $colsDesktop = '3';
if ($colsTablet === '1' || $colsTablet === '') $colsTablet = '2';
if ($colsMobile === '1' || $colsMobile === '') $colsMobile = '1';
// Bootstrap column classes
$colClass = $this->getColumnClass($colsDesktop, $colsTablet, $colsMobile);
// Query related posts
$posts = $this->getRelatedPosts($postsPerPage, $orderby, $order);
if (empty($posts)) {
return '';
}
$containerClass = 'my-5 related-posts';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= sprintf(
'<h2 class="h3 mb-4">%s</h2>',
esc_html($sectionTitle)
);
$html .= '<div class="row g-4">';
foreach ($posts as $post) {
$html .= $this->buildCardHTML($post, $colClass);
}
$html .= '</div>';
if ($showPagination) {
$html .= $this->buildPaginationHTML($data);
}
$html .= '</div>';
// Reset post data
wp_reset_postdata();
return $html;
}
private function getColumnClass(string $desktop, string $tablet, string $mobile): string
{
$desktopCols = 12 / (int)$desktop;
$tabletCols = 12 / (int)$tablet;
$mobileCols = 12 / (int)$mobile;
// Template original usa col-md-4 (3 columnas desde tablet)
// col-{mobile} col-md-{tablet/desktop}
return sprintf('col-%d col-md-%d', $mobileCols, $desktopCols);
}
private function getRelatedPosts(int $perPage, string $orderby, string $order): array
{
$currentPostId = get_the_ID();
$args = [
'post_type' => 'post',
'posts_per_page' => $perPage,
'post__not_in' => $currentPostId ? [$currentPostId] : [],
'orderby' => $orderby,
'order' => $order,
'no_found_rows' => true,
];
$query = new \WP_Query($args);
return $query->posts;
}
private function buildCardHTML(\WP_Post $post, string $colClass): string
{
$permalink = get_permalink($post);
$title = get_the_title($post);
$html = sprintf('<div class="%s">', esc_attr($colClass));
$html .= sprintf(
'<a href="%s" class="text-decoration-none">',
esc_url($permalink)
);
$html .= '<div class="card h-100">';
$html .= '<div class="card-body d-flex align-items-center justify-content-center">';
$html .= sprintf(
'<h5 class="card-title h6 mb-0 text-center">%s</h5>',
esc_html($title)
);
$html .= '</div>';
$html .= '</div>';
$html .= '</a>';
$html .= '</div>';
return $html;
}
private function buildPaginationHTML(array $data): string
{
$content = $data['content'] ?? [];
$textFirst = $content['pagination_text_first'] ?? 'Inicio';
$textLast = $content['pagination_text_last'] ?? 'Fin';
$textMore = $content['pagination_text_more'] ?? 'Ver mas';
$html = '<nav aria-label="' . esc_attr__('Navegacion de posts relacionados', 'roi-theme') . '" class="mt-5">';
$html .= '<ul class="pagination justify-content-center">';
// First page
$html .= '<li class="page-item">';
$html .= sprintf(
'<a class="page-link" href="#" aria-label="%s">%s</a>',
esc_attr($textFirst),
esc_html($textFirst)
);
$html .= '</li>';
// Page numbers (static for now, can be enhanced with AJAX later)
for ($i = 1; $i <= 5; $i++) {
$activeClass = $i === 1 ? ' active' : '';
$ariaCurrent = $i === 1 ? ' aria-current="page"' : '';
$html .= sprintf(
'<li class="page-item%s"%s><a class="page-link" href="#">%d</a></li>',
$activeClass,
$ariaCurrent,
$i
);
}
// More link
$html .= '<li class="page-item">';
$html .= sprintf(
'<a class="page-link" href="#">%s</a>',
esc_html($textMore)
);
$html .= '</li>';
// Last page
$html .= '<li class="page-item">';
$html .= sprintf(
'<a class="page-link" href="#" aria-label="%s">%s</a>',
esc_attr($textLast),
esc_html($textLast)
);
$html .= '</li>';
$html .= '</ul>';
$html .= '</nav>';
return $html;
}
}

View File

@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\SocialShare\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* SocialShareRenderer - Renderiza botones de compartir en redes sociales
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Social Share
*
* CARACTERISTICAS:
* - 6 redes sociales: Facebook, Instagram, LinkedIn, WhatsApp, X, Email
* - Colores configurables por red
* - Toggle individual por red social
* - Estilos 100% desde BD via CSSGenerator
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar social share)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\SocialShare\Infrastructure\Ui
*/
final class SocialShareRenderer implements RendererInterface
{
private const NETWORKS = [
'facebook' => [
'field' => 'show_facebook',
'url_field' => 'facebook_url',
'icon' => 'bi-facebook',
'label' => 'Facebook',
'share_pattern' => 'https://www.facebook.com/sharer/sharer.php?u=%s',
],
'instagram' => [
'field' => 'show_instagram',
'url_field' => 'instagram_url',
'icon' => 'bi-instagram',
'label' => 'Instagram',
'share_pattern' => '', // Instagram no tiene share directo - requiere URL configurada
],
'linkedin' => [
'field' => 'show_linkedin',
'url_field' => 'linkedin_url',
'icon' => 'bi-linkedin',
'label' => 'LinkedIn',
'share_pattern' => 'https://www.linkedin.com/sharing/share-offsite/?url=%s',
],
'whatsapp' => [
'field' => 'show_whatsapp',
'url_field' => 'whatsapp_number',
'icon' => 'bi-whatsapp',
'label' => 'WhatsApp',
'share_pattern' => 'https://wa.me/?text=%s', // Compartir via WhatsApp
],
'twitter' => [
'field' => 'show_twitter',
'url_field' => 'twitter_url',
'icon' => 'bi-twitter-x',
'label' => 'X',
'share_pattern' => 'https://twitter.com/intent/tweet?url=%s&text=%s',
],
'email' => [
'field' => 'show_email',
'url_field' => 'email_address',
'icon' => 'bi-envelope',
'label' => 'Email',
'share_pattern' => 'mailto:?subject=%s&body=%s', // Compartir via Email
],
];
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
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);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'social-share';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$buttonBorderWidth = $effects['button_border_width'] ?? '2px';
// Container styles
$cssRules[] = $this->cssGenerator->generate('.social-share-container', [
'margin-top' => $spacing['container_margin_top'] ?? '3rem',
'margin-bottom' => $spacing['container_margin_bottom'] ?? '3rem',
'padding-top' => $spacing['container_padding_top'] ?? '1.5rem',
'padding-bottom' => $spacing['container_padding_bottom'] ?? '1.5rem',
'border-top' => sprintf('%s solid %s',
$effects['border_top_width'] ?? '1px',
$colors['border_top_color'] ?? '#dee2e6'
),
]);
// Label styles
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-label', [
'font-size' => $typography['label_font_size'] ?? '1rem',
'color' => $colors['label_color'] ?? '#6c757d',
'margin-bottom' => $spacing['label_margin_bottom'] ?? '1rem',
]);
// Buttons wrapper
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-buttons', [
'display' => 'flex',
'flex-wrap' => 'wrap',
'gap' => $spacing['buttons_gap'] ?? '0.5rem',
]);
// Base button styles
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-buttons .btn', [
'padding' => $spacing['button_padding'] ?? '0.25rem 0.5rem',
'font-size' => $typography['icon_font_size'] ?? '1rem',
'border-width' => $buttonBorderWidth,
'border-radius' => $effects['button_border_radius'] ?? '0.375rem',
'transition' => "all {$transitionDuration} ease",
'background-color' => $colors['button_background'] ?? '#ffffff',
]);
// Hover effect
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-buttons .btn:hover', [
'box-shadow' => $effects['hover_box_shadow'] ?? '0 4px 12px rgba(0, 0, 0, 0.15)',
]);
// Network-specific colors
$networkColors = [
'facebook' => $colors['facebook_color'] ?? '#0d6efd',
'instagram' => $colors['instagram_color'] ?? '#dc3545',
'linkedin' => $colors['linkedin_color'] ?? '#0dcaf0',
'whatsapp' => $colors['whatsapp_color'] ?? '#198754',
'twitter' => $colors['twitter_color'] ?? '#212529',
'email' => $colors['email_color'] ?? '#6c757d',
];
foreach ($networkColors as $network => $color) {
// Outline style
$cssRules[] = $this->cssGenerator->generate(".social-share-container .btn-share-{$network}", [
'color' => $color,
'border-color' => $color,
]);
// Hover fills the button
$cssRules[] = $this->cssGenerator->generate(".social-share-container .btn-share-{$network}:hover", [
'background-color' => $color,
'color' => '#ffffff',
]);
}
// Responsive visibility (normalizar booleanos desde BD)
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnDesktop = $showOnDesktop === true || $showOnDesktop === '1' || $showOnDesktop === 1;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$showOnMobile = $showOnMobile === true || $showOnMobile === '1' || $showOnMobile === 1;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.social-share-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.social-share-container { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$networks = $data['networks'] ?? [];
$labelText = $content['label_text'] ?? 'Compartir:';
$showLabel = $content['show_label'] ?? true;
$showLabel = $showLabel === true || $showLabel === '1' || $showLabel === 1;
$html = '<div class="social-share-container">';
// Label
if ($showLabel && !empty($labelText)) {
$html .= sprintf(
'<p class="share-label">%s</p>',
esc_html($labelText)
);
}
// Buttons wrapper
$html .= '<div class="share-buttons">';
// Get current post data for share URLs
$shareUrl = $this->getCurrentUrl();
$shareTitle = $this->getCurrentTitle();
foreach (self::NETWORKS as $networkKey => $networkData) {
$fieldKey = $networkData['field'];
$isEnabled = $networks[$fieldKey] ?? true;
$isEnabled = $isEnabled === true || $isEnabled === '1' || $isEnabled === 1;
if (!$isEnabled) {
continue;
}
// Obtener URL configurada para esta red
$urlFieldKey = $networkData['url_field'];
$configuredUrl = $networks[$urlFieldKey] ?? '';
$shareHref = $this->buildNetworkUrl(
$networkKey,
$configuredUrl,
$networkData['share_pattern'],
$shareUrl,
$shareTitle
);
// Si no hay URL válida usar "#" como fallback (para mantener el icono visible)
if (empty($shareHref)) {
$shareHref = '#';
}
$ariaLabel = sprintf('Compartir en %s', $networkData['label']);
$html .= sprintf(
'<a href="%s" class="btn btn-share-%s" aria-label="%s" target="_blank" rel="noopener noreferrer">
<i class="bi %s"></i>
</a>',
esc_url($shareHref),
esc_attr($networkKey),
esc_attr($ariaLabel),
esc_attr($networkData['icon'])
);
}
$html .= '</div>'; // .share-buttons
$html .= '</div>'; // .social-share-container
return $html;
}
private function getCurrentUrl(): string
{
if (is_singular()) {
return get_permalink() ?: '';
}
return home_url(add_query_arg([], $GLOBALS['wp']->request ?? ''));
}
private function getCurrentTitle(): string
{
if (is_singular()) {
return get_the_title() ?: '';
}
return wp_title('', false) ?: get_bloginfo('name');
}
/**
* Construye la URL para un botón de red social
*
* Prioridad:
* 1. URL configurada por el usuario → enlace directo al perfil
* 2. Sin URL configurada → usar patrón de compartir (si existe)
*/
private function buildNetworkUrl(
string $network,
string $configuredUrl,
string $sharePattern,
string $pageUrl,
string $pageTitle
): string {
// Si hay URL configurada, usarla directamente
if (!empty($configuredUrl)) {
return $this->formatConfiguredUrl($network, $configuredUrl);
}
// Si no hay URL configurada pero existe patrón de compartir
if (!empty($sharePattern)) {
return $this->formatShareUrl($network, $sharePattern, $pageUrl, $pageTitle);
}
return '#';
}
/**
* Formatea URL configurada según el tipo de red
*/
private function formatConfiguredUrl(string $network, string $url): string
{
switch ($network) {
case 'whatsapp':
// Para WhatsApp, el número debe ir sin el +
$number = preg_replace('/[^0-9]/', '', $url);
return "https://wa.me/{$number}";
case 'email':
// Para email, agregar mailto: si no lo tiene
if (!str_starts_with($url, 'mailto:')) {
return "mailto:{$url}";
}
return $url;
default:
return $url;
}
}
/**
* Formatea URL de compartir usando el patrón
*/
private function formatShareUrl(string $network, string $pattern, string $url, string $title): string
{
$encodedUrl = rawurlencode($url);
$encodedTitle = rawurlencode($title);
switch ($network) {
case 'twitter':
return sprintf($pattern, $encodedUrl, $encodedTitle);
case 'whatsapp':
$text = $title . ' - ' . $url;
return sprintf($pattern, rawurlencode($text));
case 'email':
return sprintf($pattern, $encodedTitle, $encodedUrl);
default:
return sprintf($pattern, $encodedUrl);
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
}

View File

@@ -0,0 +1,491 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\TableOfContents\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use DOMDocument;
use DOMXPath;
/**
* TableOfContentsRenderer - Renderiza tabla de contenido con navegacion automatica
*
* RESPONSABILIDAD: Generar HTML y CSS de la tabla de contenido
*
* CARACTERISTICAS:
* - Generacion automatica desde headings del contenido
* - ScrollSpy para navegacion activa
* - Sticky positioning configurable
* - Smooth scroll
* - Estilos 100% desde BD via CSSGenerator
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar TOC)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\TableOfContents\Infrastructure\Ui
*/
final class TableOfContentsRenderer implements RendererInterface
{
private array $headingCounter = [];
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$tocItems = $this->generateTocItems($data);
if (empty($tocItems)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $tocItems);
$script = $this->buildScript($data);
return sprintf("<style>%s</style>\n%s\n%s", $css, $html, $script);
}
public function supports(string $componentType): bool
{
return $componentType === 'table-of-contents';
}
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 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
private function generateTocItems(array $data): array
{
$content = $data['content'] ?? [];
$autoGenerate = $content['auto_generate'] ?? true;
if (!$autoGenerate) {
return [];
}
$headingLevelsStr = $content['heading_levels'] ?? 'h2,h3';
$headingLevels = array_map('trim', explode(',', $headingLevelsStr));
return $this->generateTocFromContent($headingLevels);
}
private function generateTocFromContent(array $headingLevels): array
{
global $post;
if (!$post || empty($post->post_content)) {
return [];
}
$content = apply_filters('the_content', $post->post_content);
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$tocItems = [];
$xpathQuery = implode(' | ', array_map(function($level) {
return '//' . $level;
}, $headingLevels));
$headings = $xpath->query($xpathQuery);
if ($headings->length === 0) {
return [];
}
foreach ($headings as $heading) {
$tagName = strtolower($heading->tagName);
$level = intval(substr($tagName, 1));
$text = trim($heading->textContent);
if (empty($text)) {
continue;
}
$existingId = $heading->getAttribute('id');
if (empty($existingId)) {
$anchor = $this->generateAnchorId($text);
$this->addIdToHeading($text, $anchor);
} else {
$anchor = $existingId;
}
$tocItems[] = [
'text' => $text,
'anchor' => $anchor,
'level' => $level
];
}
return $tocItems;
}
private function generateAnchorId(string $text): string
{
$id = strtolower($text);
$id = remove_accents($id);
$id = preg_replace('/[^a-z0-9]+/', '-', $id);
$id = trim($id, '-');
$baseId = $id;
$count = 1;
while (isset($this->headingCounter[$id])) {
$id = $baseId . '-' . $count;
$count++;
}
$this->headingCounter[$id] = true;
return $id;
}
private function addIdToHeading(string $headingText, string $anchorId): void
{
add_filter('the_content', function($content) use ($headingText, $anchorId) {
$pattern = '/<(h[2-6])([^>]*)>(\s*)' . preg_quote($headingText, '/') . '(\s*)<\/\1>/i';
$replacement = '<$1 id="' . esc_attr($anchorId) . '"$2>$3' . $headingText . '$4</$1>';
return preg_replace($pattern, $replacement, $content, 1);
}, 20);
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$effects = $data['visual_effects'] ?? [];
$behavior = $data['behavior'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
// Container styles - Flexbox layout for proper scrolling
$cssRules[] = $this->cssGenerator->generate('.toc-container', [
'background-color' => $colors['background_color'] ?? '#ffffff',
'border' => ($effects['border_width'] ?? '1px') . ' solid ' . ($colors['border_color'] ?? '#E6E9ED'),
'border-radius' => $effects['border_radius'] ?? '8px',
'box-shadow' => $effects['box_shadow'] ?? '0 2px 8px rgba(0, 0, 0, 0.08)',
'padding' => $spacing['container_padding'] ?? '12px 16px',
'margin-bottom' => $spacing['margin_bottom'] ?? '13px',
'max-height' => $behavior['max_height'] ?? 'calc(100vh - 71px - 10px - 250px - 15px - 15px)',
'display' => 'flex',
'flex-direction' => 'column',
'overflow' => 'visible',
]);
// Sticky behavior - aplica al wrapper .sidebar-sticky de single.php
// NO al .toc-container individual (ver template líneas 817-835)
if (($behavior['is_sticky'] ?? true)) {
$cssRules[] = $this->cssGenerator->generate('.sidebar-sticky', [
'position' => 'sticky',
'top' => '85px',
'display' => 'flex',
'flex-direction' => 'column',
]);
}
// Custom scrollbar
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar', [
'width' => $spacing['scrollbar_width'] ?? '6px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-track', [
'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-thumb', [
'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
// Title styles - Color #1e3a5f = navy-primary del Design System
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-title', [
'font-size' => $typography['title_font_size'] ?? '1rem',
'font-weight' => $typography['title_font_weight'] ?? '600',
'color' => $colors['title_color'] ?? '#1e3a5f',
'padding-bottom' => $spacing['title_padding_bottom'] ?? '8px',
'margin-bottom' => $spacing['title_margin_bottom'] ?? '0.75rem',
'border-bottom' => '2px solid ' . ($colors['title_border_color'] ?? '#E6E9ED'),
'margin-top' => '0',
]);
// List styles - Scrollable area with flex
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list', [
'margin' => '0',
'padding' => '0',
'padding-right' => '0.5rem',
'list-style' => 'none',
'overflow-y' => 'auto',
'flex' => '1',
'min-height' => '0',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list li', [
'margin-bottom' => $spacing['item_margin_bottom'] ?? '0.15rem',
]);
// Link styles - Color #495057 = neutral-600 del template
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link', [
'display' => 'block',
'font-size' => $typography['link_font_size'] ?? '0.9rem',
'line-height' => $typography['link_line_height'] ?? '1.3',
'color' => $colors['link_color'] ?? '#495057',
'text-decoration' => 'none',
'padding' => $spacing['link_padding'] ?? '0.3rem 0.85rem',
'border-radius' => $effects['link_border_radius'] ?? '4px',
'border-left' => ($effects['active_border_left_width'] ?? '3px') . ' solid transparent',
'transition' => "all {$transitionDuration} ease",
]);
// Link hover - Color #1e3a5f = navy-primary del Design System
// Template: background, border-left-color, color
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link:hover', [
'color' => $colors['link_hover_color'] ?? '#1e3a5f',
'background-color' => $colors['link_hover_background'] ?? '#F9FAFB',
'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f',
]);
// Active link - Color #1e3a5f = navy-primary del Design System
// Template: font-weight: 600
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link.active', [
'color' => $colors['active_text_color'] ?? '#1e3a5f',
'background-color' => $colors['active_background_color'] ?? '#F9FAFB',
'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f',
'font-weight' => '600',
]);
// Level indentation
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-3 .toc-link', [
'padding-left' => $spacing['level_three_padding_left'] ?? '1.5rem',
'font-size' => $typography['level_three_font_size'] ?? '0.85rem',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-4 .toc-link', [
'padding-left' => $spacing['level_four_padding_left'] ?? '2rem',
'font-size' => $typography['level_four_font_size'] ?? '0.8rem',
]);
// Scrollbar for toc-list
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar', [
'width' => $spacing['scrollbar_width'] ?? '6px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-track', [
'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb', [
'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb:hover', [
'background' => $colors['active_border_color'] ?? '#1e3a5f',
]);
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? false;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.toc-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.toc-container { display: none !important; }
}";
}
// Responsive layout adjustments
$cssRules[] = "@media (max-width: 991px) {
.sidebar-sticky {
position: relative !important;
top: 0 !important;
}
.toc-container {
margin-bottom: 2rem;
}
.toc-container .toc-list {
max-height: 300px;
}
}";
return implode("\n", $cssRules);
}
private function buildHTML(array $data, array $tocItems): string
{
$content = $data['content'] ?? [];
$title = $content['title'] ?? 'Tabla de Contenido';
// NOTA: El sticky behavior se maneja en el wrapper .sidebar-sticky de single.php
// El TOC no debe tener la clase sidebar-sticky - está dentro del wrapper
$html = '<div class="toc-container">';
$html .= sprintf(
'<h4 class="toc-title">%s</h4>',
esc_html($title)
);
$html .= '<ol class="list-unstyled toc-list">';
foreach ($tocItems as $item) {
$text = $item['text'] ?? '';
$anchor = $item['anchor'] ?? '';
$level = $item['level'] ?? 2;
if (empty($text) || empty($anchor)) {
continue;
}
$indentClass = $level > 2 ? 'toc-level-' . $level : '';
$html .= sprintf(
'<li class="%s"><a href="#%s" class="toc-link" data-level="%d">%s</a></li>',
esc_attr($indentClass),
esc_attr($anchor),
intval($level),
esc_html($text)
);
}
$html .= '</ol>';
$html .= '</div>';
return $html;
}
private function buildScript(array $data): string
{
$content = $data['content'] ?? [];
$behavior = $data['behavior'] ?? [];
$smoothScroll = $content['smooth_scroll'] ?? true;
$scrollOffset = intval($behavior['scroll_offset'] ?? 100);
if (!$smoothScroll) {
return '';
}
$script = <<<JS
<script>
document.addEventListener('DOMContentLoaded', function() {
var tocLinks = document.querySelectorAll('.toc-link');
var offsetTop = {$scrollOffset};
tocLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var targetId = this.getAttribute('href');
var targetElement = document.querySelector(targetId);
if (targetElement) {
var elementPosition = targetElement.getBoundingClientRect().top;
var offsetPosition = elementPosition + window.pageYOffset - offsetTop;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
});
});
// ScrollSpy
var sections = [];
tocLinks.forEach(function(link) {
var id = link.getAttribute('href').substring(1);
var section = document.getElementById(id);
if (section) {
sections.push({ id: id, element: section });
}
});
function updateActiveLink() {
var scrollPosition = window.pageYOffset + offsetTop + 50;
var currentSection = '';
sections.forEach(function(section) {
if (section.element.offsetTop <= scrollPosition) {
currentSection = section.id;
}
});
tocLinks.forEach(function(link) {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + currentSection) {
link.classList.add('active');
}
});
}
window.addEventListener('scroll', updateActiveLink);
updateActiveLink();
});
</script>
JS;
return $script;
}
}

View File

@@ -0,0 +1,466 @@
<?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 CSS usando CSSGeneratorService
$css = $this->generateCSS($data);
// Generar HTML
$html = $this->buildHTML($data);
// Combinar todo
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
*
* @param array $data Datos del componente
* @return string CSS generado
*/
private 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';
}
}