Files
roi-theme/Public/CtaLetsTalk/Infrastructure/Ui/CtaLetsTalkRenderer.php
FrankZamora ffe6ea8e65 feat(visibility): añadir opción "Ocultar para usuarios logueados" (Plan 99.16)
- Crear UserVisibilityHelper centralizado en Shared/Infrastructure/Services
- Añadir campo hide_for_logged_in en schemas de 4 componentes
- Integrar validación en Renderers: TopBar, LetsTalk, CTASidebar, CTAPost
- Añadir checkbox UI en FormBuilders de los 4 componentes
- Refactorizar adsense-placement.php para usar el helper centralizado
- Deprecar función roi_should_hide_for_logged_in() (backwards compatible)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:28:53 -06:00

351 lines
11 KiB
PHP

<?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;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/**
* 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
{
private const COMPONENT_NAME = 'cta-lets-talk';
/**
* @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 (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
// Validar visibilidad por usuario logueado
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
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 === self::COMPONENT_NAME;
}
/**
* 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;
}
/**
* 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
*/
public 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)
);
}
}