Fase-01: Preparación del entorno y estructura inicial

- Verificación de entorno XAMPP (PHP 8.0.30, Composer 2.9.1, WP-CLI 2.12.0)
- Configuración de Composer con PSR-4 para 24 namespaces
- Configuración de PHPUnit con 140 tests preparados
- Configuración de PHPCS con WordPress Coding Standards
- Scripts de backup y rollback con mejoras de seguridad
- Estructura de contextos (admin/, public/, shared/)
- Schemas JSON para 11 componentes del sistema
- Código fuente inicial con arquitectura limpia en src/
- Documentación de procedimientos de emergencia

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-19 16:34:49 -06:00
parent 677fbd4368
commit 90de6df77c
86 changed files with 16098 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
{
"component_name": "contact-form-section",
"version": "1.0.0",
"description": "Sección de contacto con información y formulario funcional mediante AJAX",
"groups": {
"section": {
"label": "Configuración de la Sección",
"priority": 10,
"fields": {
"show_section": {
"type": "boolean",
"label": "Mostrar sección",
"default": true,
"description": "Activar o desactivar la sección completa"
},
"section_title": {
"type": "text",
"label": "Título de la sección",
"default": "¿Tienes alguna pregunta?",
"required": true,
"description": "Título principal de la sección de contacto"
},
"section_subtitle": {
"type": "textarea",
"label": "Subtítulo",
"default": "Completa el formulario y nuestro equipo te responderá en menos de 24 horas.",
"description": "Descripción o subtítulo de la sección"
}
}
},
"contact_info": {
"label": "Información de Contacto",
"priority": 20,
"fields": {
"phone_enabled": {
"type": "boolean",
"label": "Mostrar teléfono",
"default": true
},
"phone_label": {
"type": "text",
"label": "Etiqueta de teléfono",
"default": "Teléfono"
},
"phone_value": {
"type": "text",
"label": "Número de teléfono",
"default": "+52 55 1234 5678"
},
"email_enabled": {
"type": "boolean",
"label": "Mostrar email",
"default": true
},
"email_label": {
"type": "text",
"label": "Etiqueta de email",
"default": "Email"
},
"email_value": {
"type": "email",
"label": "Dirección de email",
"default": "contacto@example.com"
},
"location_enabled": {
"type": "boolean",
"label": "Mostrar ubicación",
"default": true
},
"location_label": {
"type": "text",
"label": "Etiqueta de ubicación",
"default": "Ubicación"
},
"location_value": {
"type": "text",
"label": "Ubicación",
"default": "Ciudad de México, México"
}
}
},
"form": {
"label": "Configuración del Formulario",
"priority": 30,
"fields": {
"submit_button_text": {
"type": "text",
"label": "Texto del botón",
"default": "Enviar Mensaje",
"required": true,
"description": "Texto del botón de envío"
},
"submit_button_icon": {
"type": "text",
"label": "Ícono del botón",
"default": "bi-send-fill",
"description": "Clase de Bootstrap Icons"
},
"success_message": {
"type": "textarea",
"label": "Mensaje de éxito",
"default": "¡Gracias! Tu mensaje ha sido enviado correctamente. Te responderemos pronto.",
"description": "Mensaje al enviar exitosamente"
},
"error_message": {
"type": "textarea",
"label": "Mensaje de error",
"default": "Hubo un error al enviar el mensaje. Por favor, intenta de nuevo.",
"description": "Mensaje al fallar el envío"
},
"to_email": {
"type": "email",
"label": "Email de destino",
"default": "",
"description": "Email donde se recibirán los mensajes (deja vacío para usar el admin email)"
}
}
},
"styles": {
"label": "Estilos",
"priority": 40,
"fields": {
"background_color": {
"type": "text",
"label": "Clase de fondo",
"default": "bg-secondary bg-opacity-25",
"description": "Clase de Bootstrap para el fondo"
},
"icon_color": {
"type": "color",
"label": "Color de íconos",
"default": "#FF8600",
"description": "Color de los íconos de contacto"
},
"button_bg_color": {
"type": "color",
"label": "Color del botón",
"default": "#FF8600",
"description": "Color de fondo del botón"
},
"button_hover_bg": {
"type": "color",
"label": "Color del botón (hover)",
"default": "#FF6B00",
"description": "Color de fondo del botón al hover"
}
}
}
}
}

View File

@@ -0,0 +1,186 @@
{
"component_name": "contact-modal",
"version": "1.0.0",
"description": "Modal de contacto Bootstrap 5 con formulario AJAX para consultas de clientes",
"groups": {
"general": {
"label": "Configuración General",
"priority": 10,
"fields": {
"modal_title": {
"type": "text",
"label": "Título del modal",
"default": "¿Tienes alguna pregunta?",
"required": true,
"description": "Título que aparece en el encabezado del modal"
},
"modal_description": {
"type": "textarea",
"label": "Descripción",
"default": "Completa el formulario y nuestro equipo te responderá en menos de 24 horas.",
"description": "Texto descriptivo debajo del título"
}
}
},
"form_fields": {
"label": "Campos del Formulario",
"priority": 20,
"fields": {
"fullName": {
"type": "object",
"label": "Campo Nombre Completo",
"default": {
"label": "Nombre completo",
"placeholder": "",
"required": true
},
"fields": {
"label": {"type": "text", "label": "Etiqueta"},
"placeholder": {"type": "text", "label": "Placeholder"},
"required": {"type": "boolean", "label": "Requerido"}
}
},
"company": {
"type": "object",
"label": "Campo Empresa",
"default": {
"label": "Empresa",
"placeholder": "",
"required": false
},
"fields": {
"label": {"type": "text", "label": "Etiqueta"},
"placeholder": {"type": "text", "label": "Placeholder"},
"required": {"type": "boolean", "label": "Requerido"}
}
},
"whatsapp": {
"type": "object",
"label": "Campo WhatsApp",
"default": {
"label": "WhatsApp",
"placeholder": "",
"required": true
},
"fields": {
"label": {"type": "text", "label": "Etiqueta"},
"placeholder": {"type": "text", "label": "Placeholder"},
"required": {"type": "boolean", "label": "Requerido"}
}
},
"email": {
"type": "object",
"label": "Campo Email",
"default": {
"label": "Correo electrónico",
"placeholder": "",
"required": true
},
"fields": {
"label": {"type": "text", "label": "Etiqueta"},
"placeholder": {"type": "text", "label": "Placeholder"},
"required": {"type": "boolean", "label": "Requerido"}
}
},
"comments": {
"type": "object",
"label": "Campo Comentarios",
"default": {
"label": "¿En qué podemos ayudarte?",
"placeholder": "",
"required": false,
"rows": 4
},
"fields": {
"label": {"type": "text", "label": "Etiqueta"},
"placeholder": {"type": "text", "label": "Placeholder"},
"required": {"type": "boolean", "label": "Requerido"},
"rows": {"type": "number", "label": "Filas"}
}
}
}
},
"submit_button": {
"label": "Botón de Envío",
"priority": 30,
"fields": {
"text": {
"type": "text",
"label": "Texto del botón",
"default": "Enviar Mensaje",
"required": true,
"description": "Texto que aparece en el botón de envío"
},
"icon": {
"type": "text",
"label": "Ícono del botón",
"default": "bi-send-fill",
"description": "Clase de Bootstrap Icons (ej: bi-send-fill)"
}
}
},
"messages": {
"label": "Mensajes del Sistema",
"priority": 40,
"fields": {
"success": {
"type": "textarea",
"label": "Mensaje de éxito",
"default": "Mensaje enviado exitosamente. Te responderemos pronto.",
"description": "Mensaje cuando el formulario se envía correctamente"
},
"error": {
"type": "textarea",
"label": "Mensaje de error",
"default": "Error al enviar el mensaje. Por favor intenta nuevamente.",
"description": "Mensaje cuando ocurre un error"
},
"validation_error": {
"type": "textarea",
"label": "Mensaje de validación",
"default": "Por favor completa todos los campos requeridos.",
"description": "Mensaje cuando faltan campos obligatorios"
}
}
},
"settings": {
"label": "Configuración Avanzada",
"priority": 50,
"fields": {
"modal_id": {
"type": "text",
"label": "ID del modal",
"default": "contactModal",
"readonly": true,
"description": "ID HTML del modal (no modificar)"
},
"form_id": {
"type": "text",
"label": "ID del formulario",
"default": "modalContactForm",
"readonly": true,
"description": "ID HTML del formulario (no modificar)"
},
"ajax_action": {
"type": "text",
"label": "Acción AJAX",
"default": "roi_contact_modal_submit",
"readonly": true,
"description": "Nombre de la acción AJAX (no modificar)"
},
"email_to": {
"type": "email",
"label": "Email de destino",
"default": "",
"description": "Email donde se recibirán los mensajes (vacío = admin email)"
},
"email_subject": {
"type": "text",
"label": "Asunto del email",
"default": "Nuevo mensaje de contacto desde el sitio web",
"description": "Asunto del email que se enviará"
}
}
}
}
}

View File

@@ -0,0 +1,208 @@
{
"component_name": "cta-below-content",
"version": "1.0.0",
"description": "Call to Action que se muestra debajo del contenido del post",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Activar CTA",
"default": true,
"required": true,
"description": "Activa o desactiva el componente de Call to Action"
},
"layout": {
"type": "select",
"label": "Layout del CTA",
"default": "two-column",
"options": {
"two-column": "Dos columnas (texto izquierda, botón derecha)",
"centered": "Centrado",
"stacked": "Apilado"
},
"required": true,
"description": "Distribución del contenido en el CTA"
}
}
},
"content": {
"label": "Contenido",
"priority": 20,
"fields": {
"title": {
"type": "text",
"label": "Título del CTA",
"default": "Accede a 200,000+ Análisis de Precios Unitarios",
"maxlength": 200,
"required": true,
"description": "Título principal que aparece en el CTA"
},
"subtitle": {
"type": "textarea",
"label": "Subtítulo del CTA",
"default": "Consulta estructuras completas, insumos y dosificaciones de los APUs más utilizados en construcción en México.",
"maxlength": 500,
"rows": 3,
"required": true,
"description": "Texto descriptivo que complementa el título"
}
}
},
"button": {
"label": "Configuración del Botón",
"priority": 30,
"fields": {
"button_text": {
"type": "text",
"label": "Texto del botón",
"default": "Ver Catálogo Completo",
"maxlength": 100,
"required": true,
"description": "Texto que aparece en el botón de acción"
},
"button_url": {
"type": "url",
"label": "URL del botón",
"default": "#",
"required": true,
"description": "URL de destino al hacer clic en el botón"
},
"button_target": {
"type": "select",
"label": "Abrir enlace en",
"default": "_self",
"options": {
"_self": "Misma ventana",
"_blank": "Nueva ventana"
},
"description": "Atributo target del enlace del botón"
},
"button_color": {
"type": "select",
"label": "Color del botón",
"default": "light",
"options": {
"light": "Blanco",
"dark": "Negro",
"primary": "Azul",
"success": "Verde",
"danger": "Rojo",
"warning": "Amarillo"
},
"description": "Color de fondo del botón"
},
"button_size": {
"type": "select",
"label": "Tamaño del botón",
"default": "lg",
"options": {
"sm": "Pequeño",
"md": "Mediano",
"lg": "Grande"
},
"description": "Tamaño del botón de acción"
},
"show_icon": {
"type": "boolean",
"label": "Mostrar icono en botón",
"default": true,
"description": "Muestra u oculta el icono de flecha en el botón"
},
"icon_class": {
"type": "text",
"label": "Clase del icono",
"default": "bi bi-arrow-right",
"conditional_logic": {
"field": "show_icon",
"operator": "==",
"value": true
},
"description": "Clase de Bootstrap Icons para el icono del botón"
}
}
},
"styles": {
"label": "Estilos y Colores",
"priority": 40,
"fields": {
"gradient_start_color": {
"type": "color",
"label": "Color de inicio del gradiente",
"default": "#FF8600",
"description": "Color hexadecimal de inicio del gradiente"
},
"gradient_end_color": {
"type": "color",
"label": "Color de fin del gradiente",
"default": "#FFB800",
"description": "Color hexadecimal de fin del gradiente"
},
"gradient_angle": {
"type": "number",
"label": "Ángulo del gradiente",
"default": 135,
"min": 0,
"max": 360,
"description": "Ángulo en grados del gradiente (0-360)"
},
"text_color": {
"type": "color",
"label": "Color del texto",
"default": "#FFFFFF",
"description": "Color del texto del título y subtítulo"
},
"container_classes": {
"type": "text",
"label": "Clases CSS del contenedor",
"default": "my-5 p-4 rounded cta-section",
"description": "Clases CSS adicionales para el contenedor principal"
}
}
},
"advanced": {
"label": "Configuración Avanzada",
"priority": 50,
"fields": {
"animation_enabled": {
"type": "boolean",
"label": "Activar animación de entrada",
"default": false,
"description": "Activa animaciones de entrada para hacer el CTA más llamativo"
},
"animation_type": {
"type": "select",
"label": "Tipo de animación",
"default": "fade-in",
"options": {
"fade-in": "Fade In",
"slide-up": "Slide Up",
"scale": "Scale"
},
"conditional_logic": {
"field": "animation_enabled",
"operator": "==",
"value": true
},
"description": "Tipo de animación de entrada"
},
"animation_duration": {
"type": "number",
"label": "Duración de animación (ms)",
"default": 500,
"min": 100,
"max": 3000,
"step": 50,
"conditional_logic": {
"field": "animation_enabled",
"operator": "==",
"value": true
},
"description": "Duración de la animación en milisegundos"
}
}
}
}
}

View File

@@ -0,0 +1,191 @@
{
"component_name": "cta-box-sidebar",
"version": "1.0.0",
"description": "CTA Box para sidebar con llamado a la acción destacado",
"groups": {
"content": {
"label": "Contenido del CTA",
"priority": 10,
"fields": {
"title": {
"type": "text",
"label": "Título",
"default": "¿Listo para potenciar tus proyectos?",
"maxlength": 100,
"required": true,
"description": "Título principal del CTA box"
},
"description": {
"type": "textarea",
"label": "Descripción",
"default": "Accede a nuestra biblioteca completa de APUs y herramientas profesionales.",
"maxlength": 200,
"required": true,
"description": "Texto descriptivo del CTA"
}
}
},
"button": {
"label": "Configuración del Botón",
"priority": 20,
"fields": {
"button_text": {
"type": "text",
"label": "Texto del botón",
"default": "Solicitar Demo",
"maxlength": 50,
"required": true,
"description": "Texto que aparece en el botón CTA"
},
"button_icon": {
"type": "text",
"label": "Ícono del botón",
"default": "bi-calendar-check",
"description": "Clase de Bootstrap Icons (ej: bi-calendar-check)"
},
"button_action": {
"type": "select",
"label": "Acción del botón",
"default": "modal",
"options": {
"modal": "Abrir Modal",
"link": "Ir a URL",
"custom": "JavaScript Personalizado"
},
"required": true,
"description": "Tipo de acción al hacer clic"
},
"modal_target": {
"type": "text",
"label": "ID del modal",
"default": "#contactModal",
"description": "ID del modal a abrir (si button_action es 'modal')"
},
"link_url": {
"type": "url",
"label": "URL de destino",
"default": "",
"description": "URL del enlace (si button_action es 'link')"
},
"link_target": {
"type": "select",
"label": "Abrir enlace en",
"default": "_self",
"options": {
"_self": "Misma pestaña",
"_blank": "Nueva pestaña"
},
"description": "Target del enlace"
},
"custom_onclick": {
"type": "textarea",
"label": "JavaScript personalizado",
"default": "",
"description": "Código JavaScript para onclick (si button_action es 'custom')"
}
}
},
"config": {
"label": "Configuración General",
"priority": 30,
"fields": {
"height": {
"type": "text",
"label": "Altura del CTA box",
"default": "250px",
"description": "Altura del CTA box (CSS válido)"
},
"show_on_mobile": {
"type": "boolean",
"label": "Mostrar en móviles",
"default": true,
"description": "Mostrar en dispositivos móviles"
},
"custom_css_class": {
"type": "text",
"label": "Clase CSS personalizada",
"default": "",
"description": "Clase CSS adicional para el contenedor"
}
}
},
"styles": {
"label": "Estilos de Color",
"priority": 40,
"fields": {
"background_gradient": {
"type": "boolean",
"label": "Usar gradiente",
"default": false,
"description": "Usar gradiente en vez de color sólido"
},
"background_color": {
"type": "color",
"label": "Color de fondo",
"default": "#FF8600",
"description": "Color de fondo del CTA box"
},
"gradient_start": {
"type": "color",
"label": "Color inicial del gradiente",
"default": "#FF8600",
"description": "Color inicial del gradiente"
},
"gradient_end": {
"type": "color",
"label": "Color final del gradiente",
"default": "#FF6B00",
"description": "Color final del gradiente"
},
"title_color": {
"type": "color",
"label": "Color del título",
"default": "#ffffff",
"description": "Color del texto del título"
},
"description_color": {
"type": "text",
"label": "Color de la descripción",
"default": "rgba(255, 255, 255, 0.95)",
"description": "Color del texto de la descripción"
},
"button_bg_color": {
"type": "color",
"label": "Color de fondo del botón",
"default": "#ffffff",
"description": "Color de fondo del botón"
},
"button_text_color": {
"type": "color",
"label": "Color del texto del botón",
"default": "#FF8600",
"description": "Color del texto del botón"
},
"button_hover_bg": {
"type": "color",
"label": "Color de fondo del botón (hover)",
"default": "#0E2337",
"description": "Color de fondo del botón al hover"
},
"button_hover_text": {
"type": "color",
"label": "Color del texto del botón (hover)",
"default": "#ffffff",
"description": "Color del texto del botón al hover"
},
"shadow_color": {
"type": "text",
"label": "Color de sombra",
"default": "rgba(255, 133, 0, 0.2)",
"description": "Color de la sombra"
},
"border_radius": {
"type": "text",
"label": "Radio del borde",
"default": "8px",
"description": "Radio del borde (CSS válido)"
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
{
"component_name": "footer",
"version": "1.0.0",
"description": "Footer completo del sitio con 3 widgets, newsletter, copyright y redes sociales",
"groups": {
"widget_1": {
"label": "Widget 1",
"priority": 10,
"fields": {
"enabled": {
"type": "boolean",
"label": "Activar widget 1",
"default": true,
"description": "Mostrar u ocultar este widget"
},
"title": {
"type": "text",
"label": "Título del widget",
"default": "Recursos",
"description": "Título de la columna del widget"
},
"links": {
"type": "repeater",
"label": "Enlaces",
"description": "Lista de enlaces del widget",
"default": [
{"text": "Home", "url": "/"},
{"text": "Features", "url": "#features"},
{"text": "Pricing", "url": "#pricing"},
{"text": "FAQs", "url": "#faqs"},
{"text": "About", "url": "#about"}
],
"fields": {
"text": {
"type": "text",
"label": "Texto del enlace",
"required": true
},
"url": {
"type": "text",
"label": "URL",
"required": true
}
}
}
}
},
"widget_2": {
"label": "Widget 2",
"priority": 20,
"fields": {
"enabled": {
"type": "boolean",
"label": "Activar widget 2",
"default": true
},
"title": {
"type": "text",
"label": "Título del widget",
"default": "Servicios"
},
"links": {
"type": "repeater",
"label": "Enlaces",
"default": [
{"text": "Análisis", "url": "#analisis"},
{"text": "Presupuestos", "url": "#presupuestos"},
{"text": "Cotizaciones", "url": "#cotizaciones"},
{"text": "Proyectos", "url": "#proyectos"}
],
"fields": {
"text": {"type": "text", "label": "Texto", "required": true},
"url": {"type": "text", "label": "URL", "required": true}
}
}
}
},
"widget_3": {
"label": "Widget 3",
"priority": 30,
"fields": {
"enabled": {
"type": "boolean",
"label": "Activar widget 3",
"default": true
},
"title": {
"type": "text",
"label": "Título del widget",
"default": "Empresa"
},
"links": {
"type": "repeater",
"label": "Enlaces",
"default": [
{"text": "Acerca de", "url": "#acerca"},
{"text": "Blog", "url": "/blog"},
{"text": "Contacto", "url": "#contacto"},
{"text": "Política de Privacidad", "url": "/privacidad"}
],
"fields": {
"text": {"type": "text", "label": "Texto", "required": true},
"url": {"type": "text", "label": "URL", "required": true}
}
}
}
},
"newsletter": {
"label": "Newsletter",
"priority": 40,
"fields": {
"enabled": {
"type": "boolean",
"label": "Activar newsletter",
"default": true,
"description": "Mostrar u ocultar sección de newsletter"
},
"title": {
"type": "text",
"label": "Título",
"default": "Suscríbete a nuestro newsletter",
"required": true,
"description": "Título de la sección de newsletter"
},
"description": {
"type": "textarea",
"label": "Descripción",
"default": "Recibe actualizaciones mensuales sobre nuestros productos y servicios.",
"description": "Texto descriptivo debajo del título"
},
"placeholder": {
"type": "text",
"label": "Placeholder del email",
"default": "Correo electrónico",
"description": "Texto placeholder del campo de email"
},
"button_text": {
"type": "text",
"label": "Texto del botón",
"default": "Suscribirse",
"required": true,
"description": "Texto del botón de suscripción"
}
}
},
"copyright": {
"label": "Copyright",
"priority": 50,
"fields": {
"text": {
"type": "text",
"label": "Texto de copyright",
"default": "ROI Theme. Todos los derechos reservados.",
"required": true,
"description": "Texto que aparece después del año"
},
"year_auto": {
"type": "boolean",
"label": "Año automático",
"default": true,
"description": "Mostrar el año actual automáticamente"
}
}
},
"social_links": {
"label": "Redes Sociales",
"priority": 60,
"fields": {
"twitter": {
"type": "text",
"label": "Twitter URL",
"default": "",
"description": "URL completa de perfil de Twitter (deja vacío para ocultar)"
},
"instagram": {
"type": "text",
"label": "Instagram URL",
"default": "",
"description": "URL completa de perfil de Instagram"
},
"facebook": {
"type": "text",
"label": "Facebook URL",
"default": "",
"description": "URL completa de página de Facebook"
},
"linkedin": {
"type": "text",
"label": "LinkedIn URL",
"default": "",
"description": "URL completa de perfil o página de LinkedIn"
}
}
},
"styles": {
"label": "Estilos",
"priority": 70,
"fields": {
"background_color": {
"type": "text",
"label": "Clase de fondo",
"default": "bg-dark",
"description": "Clase de Bootstrap para el fondo (ej: bg-dark, bg-secondary)"
},
"text_color": {
"type": "text",
"label": "Clase de color de texto",
"default": "text-white",
"description": "Clase de Bootstrap para el color de texto"
},
"link_hover_color": {
"type": "color",
"label": "Color de enlaces al hover",
"default": "#FF8600",
"description": "Color de los enlaces cuando se pasa el mouse sobre ellos"
}
}
}
}
}

View File

@@ -0,0 +1,410 @@
{
"component_name": "hero-section",
"version": "1.0.0",
"description": "Sección hero con badges de categorías y título H1 con gradiente",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Mostrar hero section",
"default": true,
"required": true,
"description": "Activa o desactiva la sección hero"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas",
"custom": "Tipos de post específicos"
},
"required": true,
"description": "Define en qué páginas se mostrará la hero section"
},
"custom_post_types": {
"type": "text",
"label": "Tipos de post personalizados",
"default": "",
"placeholder": "Ej: post,page,producto",
"conditional_logic": {
"field": "show_on_pages",
"operator": "==",
"value": "custom"
},
"description": "Slugs de tipos de post separados por comas"
}
}
},
"categories": {
"label": "Badges de Categorías",
"priority": 20,
"fields": {
"show_categories": {
"type": "boolean",
"label": "Mostrar badges de categorías",
"default": true,
"description": "Muestra badges con las categorías del post"
},
"categories_source": {
"type": "select",
"label": "Fuente de categorías",
"default": "post_categories",
"options": {
"post_categories": "Categorías del post",
"post_tags": "Etiquetas del post",
"custom_taxonomy": "Taxonomía personalizada",
"custom_list": "Lista personalizada"
},
"conditional_logic": {
"field": "show_categories",
"operator": "==",
"value": true
},
"required": true,
"description": "Define de dónde obtener las categorías"
},
"custom_taxonomy_name": {
"type": "text",
"label": "Nombre de taxonomía personalizada",
"default": "",
"placeholder": "Ej: project_category",
"conditional_logic": {
"field": "categories_source",
"operator": "==",
"value": "custom_taxonomy"
},
"description": "Slug de la taxonomía personalizada"
},
"custom_categories_list": {
"type": "textarea",
"label": "Lista personalizada de categorías",
"default": "",
"placeholder": "Análisis de Precios|#\nConstrucción|#\nMateriales|#",
"rows": 5,
"conditional_logic": {
"field": "categories_source",
"operator": "==",
"value": "custom_list"
},
"description": "Una categoría por línea en formato: Nombre|URL"
},
"max_categories": {
"type": "number",
"label": "Máximo de categorías a mostrar",
"default": 5,
"min": 1,
"max": 20,
"conditional_logic": {
"field": "show_categories",
"operator": "==",
"value": true
},
"description": "Número máximo de badges a mostrar"
},
"category_icon": {
"type": "text",
"label": "Ícono de categoría",
"default": "bi-folder-fill",
"placeholder": "Ej: bi-folder-fill",
"conditional_logic": {
"field": "show_categories",
"operator": "==",
"value": true
},
"description": "Clase del ícono Bootstrap para los badges"
},
"categories_alignment": {
"type": "select",
"label": "Alineación de categorías",
"default": "center",
"options": {
"left": "Izquierda",
"center": "Centro",
"right": "Derecha"
},
"conditional_logic": {
"field": "show_categories",
"operator": "==",
"value": true
},
"description": "Alineación de los badges de categorías"
}
}
},
"title": {
"label": "Título Principal",
"priority": 30,
"fields": {
"title_source": {
"type": "select",
"label": "Fuente del título",
"default": "post_title",
"options": {
"post_title": "Título del post",
"custom_field": "Campo personalizado",
"custom_text": "Texto personalizado"
},
"required": true,
"description": "Define de dónde obtener el texto del título"
},
"custom_field_name": {
"type": "text",
"label": "Nombre del campo personalizado",
"default": "",
"placeholder": "Ej: hero_title",
"conditional_logic": {
"field": "title_source",
"operator": "==",
"value": "custom_field"
},
"description": "Nombre del custom field de WordPress"
},
"custom_text": {
"type": "textarea",
"label": "Texto personalizado",
"default": "",
"rows": 3,
"maxlength": 500,
"conditional_logic": {
"field": "title_source",
"operator": "==",
"value": "custom_text"
},
"description": "Texto personalizado para el título"
},
"title_tag": {
"type": "select",
"label": "Etiqueta HTML del título",
"default": "h1",
"options": {
"h1": "H1",
"h2": "H2",
"h3": "H3",
"div": "DIV"
},
"description": "Etiqueta HTML para el título"
},
"title_classes": {
"type": "text",
"label": "Clases CSS adicionales",
"default": "display-5 fw-bold",
"placeholder": "Ej: display-5 fw-bold",
"description": "Clases CSS adicionales para el título"
},
"title_alignment": {
"type": "select",
"label": "Alineación del título",
"default": "center",
"options": {
"left": "Izquierda",
"center": "Centro",
"right": "Derecha"
},
"description": "Alineación del título"
},
"enable_gradient": {
"type": "boolean",
"label": "Activar gradiente en el texto",
"default": false,
"description": "Aplica efecto de gradiente al texto del título"
},
"gradient_color_start": {
"type": "color",
"label": "Color inicial del gradiente",
"default": "#1e3a5f",
"conditional_logic": {
"field": "enable_gradient",
"operator": "==",
"value": true
},
"description": "Color de inicio del gradiente"
},
"gradient_color_end": {
"type": "color",
"label": "Color final del gradiente",
"default": "#FF8600",
"conditional_logic": {
"field": "enable_gradient",
"operator": "==",
"value": true
},
"description": "Color final del gradiente"
},
"gradient_direction": {
"type": "select",
"label": "Dirección del gradiente",
"default": "to-right",
"options": {
"to-right": "Izquierda a derecha",
"to-left": "Derecha a izquierda",
"to-bottom": "Arriba a abajo",
"to-top": "Abajo a arriba",
"diagonal": "Diagonal"
},
"conditional_logic": {
"field": "enable_gradient",
"operator": "==",
"value": true
},
"description": "Dirección del gradiente"
}
}
},
"styles": {
"label": "Estilos",
"priority": 40,
"fields": {
"background_type": {
"type": "select",
"label": "Tipo de fondo",
"default": "gradient",
"options": {
"color": "Color sólido",
"gradient": "Gradiente",
"image": "Imagen",
"none": "Sin fondo"
},
"required": true,
"description": "Tipo de fondo para la hero section"
},
"background_color": {
"type": "color",
"label": "Color de fondo",
"default": "#1e3a5f",
"conditional_logic": {
"field": "background_type",
"operator": "==",
"value": "color"
},
"description": "Color sólido de fondo"
},
"gradient_start_color": {
"type": "color",
"label": "Color inicial del gradiente",
"default": "#1e3a5f",
"conditional_logic": {
"field": "background_type",
"operator": "==",
"value": "gradient"
},
"description": "Color de inicio del gradiente de fondo"
},
"gradient_end_color": {
"type": "color",
"label": "Color final del gradiente",
"default": "#2c5282",
"conditional_logic": {
"field": "background_type",
"operator": "==",
"value": "gradient"
},
"description": "Color final del gradiente de fondo"
},
"gradient_angle": {
"type": "number",
"label": "Ángulo del gradiente (grados)",
"default": 135,
"min": 0,
"max": 360,
"conditional_logic": {
"field": "background_type",
"operator": "==",
"value": "gradient"
},
"description": "Ángulo del gradiente en grados (0-360)"
},
"background_image_url": {
"type": "media",
"label": "Imagen de fondo",
"default": "",
"media_type": "image",
"conditional_logic": {
"field": "background_type",
"operator": "==",
"value": "image"
},
"description": "Imagen de fondo para la hero section"
},
"background_overlay": {
"type": "boolean",
"label": "Overlay oscuro sobre imagen",
"default": true,
"conditional_logic": {
"field": "background_type",
"operator": "==",
"value": "image"
},
"description": "Agrega capa oscura sobre la imagen de fondo"
},
"overlay_opacity": {
"type": "number",
"label": "Opacidad del overlay (%)",
"default": 60,
"min": 0,
"max": 100,
"conditional_logic": {
"field": "background_overlay",
"operator": "==",
"value": true
},
"description": "Opacidad de la capa oscura (0-100)"
},
"text_color": {
"type": "color",
"label": "Color del texto",
"default": "#FFFFFF",
"description": "Color del texto del título y elementos"
},
"padding_vertical": {
"type": "select",
"label": "Padding vertical",
"default": "normal",
"options": {
"compact": "Compacto (2rem)",
"normal": "Normal (3rem)",
"spacious": "Espacioso (4rem)",
"extra-spacious": "Extra espacioso (5rem)"
},
"description": "Espaciado vertical de la sección"
},
"margin_bottom": {
"type": "select",
"label": "Margen inferior",
"default": "normal",
"options": {
"none": "Sin margen",
"small": "Pequeño (1rem)",
"normal": "Normal (1.5rem)",
"large": "Grande (2rem)"
},
"description": "Margen inferior de la sección"
},
"category_badge_background": {
"type": "color",
"label": "Fondo de badges",
"default": "rgba(255, 255, 255, 0.2)",
"description": "Color de fondo de los badges de categorías"
},
"category_badge_text_color": {
"type": "color",
"label": "Color del texto de badges",
"default": "#FFFFFF",
"description": "Color del texto en los badges de categorías"
},
"category_badge_blur": {
"type": "boolean",
"label": "Efecto blur en badges",
"default": true,
"description": "Aplica efecto de desenfoque (backdrop-filter) a los badges"
}
}
}
}
}

View File

@@ -0,0 +1,421 @@
{
"component_name": "navbar",
"version": "1.0.0",
"description": "Barra de navegación principal con menú Bootstrap, logo y botón CTA",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Mostrar navbar",
"default": true,
"required": true,
"description": "Activa o desactiva la barra de navegación"
},
"is_sticky": {
"type": "boolean",
"label": "Navbar fijo (sticky)",
"default": true,
"description": "Mantiene el navbar fijo al hacer scroll"
},
"hide_on_scroll": {
"type": "boolean",
"label": "Ocultar al hacer scroll hacia abajo",
"default": false,
"description": "Oculta el navbar cuando el usuario hace scroll hacia abajo"
},
"show_on_mobile": {
"type": "boolean",
"label": "Mostrar en dispositivos móviles",
"default": true,
"description": "Muestra el navbar en pantallas pequeñas con menú hamburguesa"
}
}
},
"logo": {
"label": "Logo",
"priority": 20,
"fields": {
"logo_type": {
"type": "select",
"label": "Tipo de logo",
"default": "image",
"options": {
"image": "Imagen",
"text": "Texto",
"none": "Sin logo"
},
"required": true,
"description": "Define el tipo de logo a mostrar"
},
"logo_image_url": {
"type": "media",
"label": "Imagen del logo",
"default": "",
"media_type": "image",
"conditional_logic": {
"field": "logo_type",
"operator": "==",
"value": "image"
},
"required": true,
"description": "Sube la imagen del logo (recomendado: PNG transparente)"
},
"logo_image_width": {
"type": "number",
"label": "Ancho del logo (px)",
"default": 150,
"min": 50,
"max": 400,
"conditional_logic": {
"field": "logo_type",
"operator": "==",
"value": "image"
},
"description": "Ancho del logo en píxeles"
},
"logo_text": {
"type": "text",
"label": "Texto del logo",
"default": "",
"maxlength": 50,
"conditional_logic": {
"field": "logo_type",
"operator": "==",
"value": "text"
},
"description": "Texto a mostrar como logo"
},
"logo_link": {
"type": "url",
"label": "Enlace del logo",
"default": "",
"placeholder": "Dejar vacío para usar la URL del home",
"description": "URL de destino al hacer clic en el logo"
},
"logo_position": {
"type": "select",
"label": "Posición del logo",
"default": "left",
"options": {
"left": "Izquierda",
"center": "Centro",
"right": "Derecha"
},
"description": "Posición del logo en el navbar"
}
}
},
"menu": {
"label": "Menú de Navegación",
"priority": 30,
"fields": {
"menu_location": {
"type": "select",
"label": "Ubicación del menú",
"default": "primary",
"options": {
"primary": "Menú Principal",
"secondary": "Menú Secundario",
"custom": "Menú personalizado"
},
"required": true,
"description": "Selecciona qué menú de WordPress mostrar"
},
"custom_menu_id": {
"type": "number",
"label": "ID del menú personalizado",
"default": 0,
"min": 0,
"conditional_logic": {
"field": "menu_location",
"operator": "==",
"value": "custom"
},
"description": "ID del menú personalizado de WordPress"
},
"menu_alignment": {
"type": "select",
"label": "Alineación del menú",
"default": "left",
"options": {
"left": "Izquierda",
"center": "Centro",
"right": "Derecha"
},
"description": "Alineación de los items del menú"
},
"enable_dropdowns": {
"type": "boolean",
"label": "Habilitar menús desplegables",
"default": true,
"description": "Permite submenús desplegables"
},
"dropdown_animation": {
"type": "select",
"label": "Animación de dropdowns",
"default": "fade",
"options": {
"none": "Sin animación",
"fade": "Aparecer gradualmente",
"slide": "Deslizar"
},
"conditional_logic": {
"field": "enable_dropdowns",
"operator": "==",
"value": true
},
"description": "Tipo de animación para los submenús"
},
"mobile_breakpoint": {
"type": "select",
"label": "Breakpoint para menú móvil",
"default": "lg",
"options": {
"sm": "Small (576px)",
"md": "Medium (768px)",
"lg": "Large (992px)",
"xl": "Extra Large (1200px)"
},
"description": "Punto de quiebre para mostrar menú hamburguesa"
}
}
},
"cta_button": {
"label": "Botón CTA (Call to Action)",
"priority": 40,
"fields": {
"button_enabled": {
"type": "boolean",
"label": "Mostrar botón CTA",
"default": true,
"description": "Activa o desactiva el botón de llamada a la acción"
},
"button_text": {
"type": "text",
"label": "Texto del botón",
"default": "Let's Talk",
"maxlength": 30,
"conditional_logic": {
"field": "button_enabled",
"operator": "==",
"value": true
},
"required": true,
"description": "Texto que aparece en el botón"
},
"button_icon": {
"type": "text",
"label": "Ícono del botón",
"default": "bi-lightning-charge-fill",
"placeholder": "Ej: bi-lightning-charge-fill",
"conditional_logic": {
"field": "button_enabled",
"operator": "==",
"value": true
},
"description": "Clase del ícono Bootstrap (dejar vacío para sin ícono)"
},
"button_action_type": {
"type": "select",
"label": "Tipo de acción del botón",
"default": "modal",
"options": {
"modal": "Abrir modal",
"link": "Ir a URL",
"scroll": "Scroll a sección"
},
"conditional_logic": {
"field": "button_enabled",
"operator": "==",
"value": true
},
"required": true,
"description": "Acción que se ejecuta al hacer clic"
},
"button_modal_target": {
"type": "text",
"label": "ID del modal",
"default": "#contactModal",
"placeholder": "#contactModal",
"conditional_logic": {
"field": "button_action_type",
"operator": "==",
"value": "modal"
},
"required": true,
"description": "ID del modal de Bootstrap a abrir (incluir #)"
},
"button_link_url": {
"type": "url",
"label": "URL del enlace",
"default": "",
"placeholder": "https://",
"conditional_logic": {
"field": "button_action_type",
"operator": "==",
"value": "link"
},
"required": true,
"description": "URL de destino del botón"
},
"button_link_target": {
"type": "select",
"label": "Abrir enlace en",
"default": "_self",
"options": {
"_self": "Misma ventana",
"_blank": "Nueva ventana"
},
"conditional_logic": {
"field": "button_action_type",
"operator": "==",
"value": "link"
},
"description": "Destino del enlace"
},
"button_scroll_target": {
"type": "text",
"label": "ID de la sección",
"default": "",
"placeholder": "#contact",
"conditional_logic": {
"field": "button_action_type",
"operator": "==",
"value": "scroll"
},
"required": true,
"description": "ID de la sección a la que hacer scroll (incluir #)"
},
"button_position": {
"type": "select",
"label": "Posición del botón",
"default": "right",
"options": {
"left": "Antes del menú",
"right": "Después del menú"
},
"conditional_logic": {
"field": "button_enabled",
"operator": "==",
"value": true
},
"description": "Ubicación del botón en el navbar"
}
}
},
"styles": {
"label": "Estilos",
"priority": 50,
"fields": {
"background_color": {
"type": "color",
"label": "Color de fondo",
"default": "#1e3a5f",
"description": "Color de fondo del navbar (por defecto: navy primary)"
},
"text_color": {
"type": "color",
"label": "Color del texto",
"default": "#FFFFFF",
"description": "Color del texto de los links del menú"
},
"hover_color": {
"type": "color",
"label": "Color hover",
"default": "#FF8600",
"description": "Color al pasar el mouse sobre los links (por defecto: orange primary)"
},
"active_color": {
"type": "color",
"label": "Color del item activo",
"default": "#FF8600",
"description": "Color del item de menú activo/actual"
},
"button_background": {
"type": "color",
"label": "Color de fondo del botón",
"default": "#FF8600",
"description": "Color de fondo del botón CTA"
},
"button_text_color": {
"type": "color",
"label": "Color del texto del botón",
"default": "#FFFFFF",
"description": "Color del texto del botón CTA"
},
"button_hover_background": {
"type": "color",
"label": "Color hover del botón",
"default": "#FF6B35",
"description": "Color de fondo del botón al hacer hover"
},
"padding_vertical": {
"type": "select",
"label": "Padding vertical",
"default": "normal",
"options": {
"compact": "Compacto (0.5rem)",
"normal": "Normal (1rem)",
"spacious": "Espacioso (1.5rem)"
},
"description": "Espaciado vertical del navbar"
},
"shadow_enabled": {
"type": "boolean",
"label": "Activar sombra",
"default": true,
"description": "Agrega sombra debajo del navbar"
},
"shadow_intensity": {
"type": "select",
"label": "Intensidad de la sombra",
"default": "medium",
"options": {
"light": "Ligera",
"medium": "Media",
"strong": "Fuerte"
},
"conditional_logic": {
"field": "shadow_enabled",
"operator": "==",
"value": true
},
"description": "Intensidad de la sombra del navbar"
},
"border_bottom_enabled": {
"type": "boolean",
"label": "Borde inferior",
"default": false,
"description": "Agrega un borde en la parte inferior del navbar"
},
"border_bottom_color": {
"type": "color",
"label": "Color del borde inferior",
"default": "#FF8600",
"conditional_logic": {
"field": "border_bottom_enabled",
"operator": "==",
"value": true
},
"description": "Color del borde inferior"
},
"border_bottom_width": {
"type": "number",
"label": "Grosor del borde (px)",
"default": 3,
"min": 1,
"max": 10,
"conditional_logic": {
"field": "border_bottom_enabled",
"operator": "==",
"value": true
},
"description": "Grosor del borde inferior en píxeles"
}
}
}
}
}

View File

@@ -0,0 +1,145 @@
{
"component_name": "related-posts",
"version": "1.0.0",
"description": "Posts relacionados al final del contenido principal",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Activar posts relacionados",
"default": true,
"required": true,
"description": "Activa o desactiva el componente de posts relacionados"
},
"section_title": {
"type": "text",
"label": "Título de la sección",
"default": "Descubre Más Contenido",
"maxlength": 200,
"required": true,
"description": "Título que aparece antes del grid de posts"
}
}
},
"query": {
"label": "Configuración de Consulta",
"priority": 20,
"fields": {
"posts_per_page": {
"type": "number",
"label": "Posts por página",
"default": 12,
"min": 1,
"max": 100,
"required": true,
"description": "Cantidad de posts a mostrar por página"
},
"post_selection": {
"type": "select",
"label": "Criterio de selección",
"default": "category",
"options": {
"category": "Misma categoría",
"tags": "Mismos tags",
"both": "Categoría y tags",
"recent": "Más recientes",
"random": "Aleatorio"
},
"required": true,
"description": "Cómo seleccionar los posts relacionados"
},
"exclude_current_post": {
"type": "boolean",
"label": "Excluir post actual",
"default": true,
"description": "Excluye el post actual de los resultados"
}
}
},
"layout": {
"label": "Diseño y Disposición",
"priority": 30,
"fields": {
"columns": {
"type": "select",
"label": "Número de columnas",
"default": "3",
"options": {
"1": "1 columna",
"2": "2 columnas",
"3": "3 columnas",
"4": "4 columnas"
},
"required": true,
"description": "Número de columnas en el grid"
},
"card_height": {
"type": "select",
"label": "Altura de cards",
"default": "equal",
"options": {
"auto": "Automática",
"equal": "Igual (centrado)"
},
"description": "Controla cómo se muestran las cards"
}
}
},
"pagination": {
"label": "Paginación",
"priority": 40,
"fields": {
"show_pagination": {
"type": "boolean",
"label": "Mostrar paginación",
"default": true,
"description": "Muestra u oculta la paginación"
},
"pagination_position": {
"type": "select",
"label": "Posición de la paginación",
"default": "center",
"options": {
"left": "Izquierda",
"center": "Centro",
"right": "Derecha"
},
"conditional_logic": {
"field": "show_pagination",
"operator": "==",
"value": true
},
"description": "Alineación de la paginación"
}
}
},
"styles": {
"label": "Estilos",
"priority": 50,
"fields": {
"container_classes": {
"type": "text",
"label": "Clases CSS del contenedor",
"default": "my-5 related-posts",
"description": "Clases CSS adicionales para el contenedor principal"
},
"grid_gap": {
"type": "select",
"label": "Espaciado del grid",
"default": "4",
"options": {
"1": "Muy pequeño",
"2": "Pequeño",
"3": "Normal",
"4": "Grande",
"5": "Muy grande"
},
"description": "Espaciado entre cards"
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
{
"component_name": "share-buttons",
"version": "1.0.0",
"description": "Botones para compartir contenido en redes sociales",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Activar botones de compartir",
"default": true,
"required": true,
"description": "Activa o desactiva los botones de compartir en redes sociales"
},
"show_label": {
"type": "boolean",
"label": "Mostrar etiqueta",
"default": true,
"description": "Muestra u oculta el texto de etiqueta antes de los botones"
},
"label_text": {
"type": "text",
"label": "Texto de etiqueta",
"default": "Compartir:",
"maxlength": 100,
"conditional_logic": {
"field": "show_label",
"operator": "==",
"value": true
},
"description": "Texto que aparece antes de los botones"
}
}
},
"networks": {
"label": "Redes Sociales",
"priority": 20,
"fields": {
"enabled_networks": {
"type": "multiselect",
"label": "Redes sociales habilitadas",
"default": ["facebook", "instagram", "linkedin", "whatsapp", "twitter", "email"],
"options": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"whatsapp": "WhatsApp",
"twitter": "Twitter / X",
"email": "Email"
},
"required": true,
"description": "Lista de redes sociales que se mostrarán"
},
"show_network_labels": {
"type": "boolean",
"label": "Mostrar nombres de redes",
"default": false,
"description": "Muestra el nombre de la red social junto al icono"
}
}
},
"button_styles": {
"label": "Estilos de Botones",
"priority": 30,
"fields": {
"button_style": {
"type": "select",
"label": "Estilo de botones",
"default": "outline",
"options": {
"outline": "Outline (contorno)",
"solid": "Solid (relleno)"
},
"required": true,
"description": "Estilo visual de los botones"
},
"button_size": {
"type": "select",
"label": "Tamaño de botones",
"default": "sm",
"options": {
"sm": "Pequeño",
"md": "Mediano",
"lg": "Grande"
},
"required": true,
"description": "Tamaño de los botones"
}
}
},
"advanced": {
"label": "Configuración Avanzada",
"priority": 40,
"fields": {
"container_classes": {
"type": "text",
"label": "Clases CSS del contenedor",
"default": "my-5 py-4 border-top",
"description": "Clases CSS adicionales para el contenedor principal"
},
"buttons_wrapper_classes": {
"type": "text",
"label": "Clases CSS del wrapper de botones",
"default": "d-flex gap-2 flex-wrap share-buttons",
"description": "Clases CSS para el wrapper de los botones"
}
}
}
}
}

View File

@@ -0,0 +1,171 @@
{
"component_name": "table-of-contents",
"version": "1.0.0",
"description": "Tabla de contenido con ScrollSpy para navegación lateral",
"groups": {
"visibility": {
"label": "Visibilidad y Comportamiento",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Activar tabla de contenido",
"default": true,
"required": true,
"description": "Activa o desactiva el componente de tabla de contenido"
},
"sticky": {
"type": "boolean",
"label": "Posición sticky",
"default": true,
"description": "Mantiene el TOC visible al hacer scroll"
},
"show_on_mobile": {
"type": "boolean",
"label": "Mostrar en móviles",
"default": false,
"description": "Muestra el TOC en dispositivos móviles"
}
}
},
"config": {
"label": "Configuración General",
"priority": 20,
"fields": {
"title": {
"type": "text",
"label": "Título del TOC",
"default": "Tabla de Contenido",
"maxlength": 100,
"required": true,
"description": "Título que aparece en el encabezado del TOC"
},
"heading_levels": {
"type": "multiselect",
"label": "Niveles de encabezados",
"default": ["h2", "h3"],
"options": {
"h2": "H2",
"h3": "H3",
"h4": "H4",
"h5": "H5",
"h6": "H6"
},
"required": true,
"description": "Niveles de encabezados a incluir en el TOC"
},
"auto_generate": {
"type": "boolean",
"label": "Generar automáticamente",
"default": true,
"description": "Generar TOC desde el contenido del post"
},
"offset_top": {
"type": "number",
"label": "Offset top (px)",
"default": 100,
"min": 0,
"max": 500,
"description": "Offset desde el top para el ScrollSpy"
},
"smooth_scroll": {
"type": "boolean",
"label": "Scroll suave",
"default": true,
"description": "Activar scroll suave al hacer clic en enlaces"
},
"max_height": {
"type": "text",
"label": "Altura máxima",
"default": "calc(100vh - 400px)",
"description": "Altura máxima del contenedor TOC (CSS válido)"
},
"custom_css_class": {
"type": "text",
"label": "Clase CSS personalizada",
"default": "",
"description": "Clase CSS adicional para el contenedor"
}
}
},
"manual_items": {
"label": "Items Manuales",
"priority": 30,
"fields": {
"items": {
"type": "repeater",
"label": "Items del TOC",
"default": [],
"description": "Items manuales si auto_generate es false",
"subfields": {
"text": {
"type": "text",
"label": "Texto del enlace",
"required": true
},
"anchor": {
"type": "text",
"label": "ID del ancla (sin #)",
"required": true
},
"level": {
"type": "number",
"label": "Nivel (2-6)",
"default": 2,
"min": 2,
"max": 6
}
}
}
}
},
"styles": {
"label": "Estilos Personalizados",
"priority": 40,
"fields": {
"background_color": {
"type": "color",
"label": "Color de fondo",
"default": "#ffffff",
"description": "Color de fondo del contenedor"
},
"border_color": {
"type": "color",
"label": "Color de borde",
"default": "#E6E9ED",
"description": "Color del borde del contenedor"
},
"title_color": {
"type": "color",
"label": "Color del título",
"default": "#0E2337",
"description": "Color del texto del título"
},
"link_color": {
"type": "color",
"label": "Color de enlaces",
"default": "#6B7280",
"description": "Color de los enlaces del TOC"
},
"link_hover_color": {
"type": "color",
"label": "Color de enlaces (hover)",
"default": "#0E2337",
"description": "Color de los enlaces al pasar el mouse"
},
"active_border_color": {
"type": "color",
"label": "Color de borde activo",
"default": "#0E2337",
"description": "Color del borde izquierdo del item activo"
},
"active_bg_color": {
"type": "color",
"label": "Color de fondo activo",
"default": "#F9FAFB",
"description": "Color de fondo del item activo"
}
}
}
}
}

View File

@@ -0,0 +1,258 @@
{
"component_name": "top_notification_bar",
"version": "1.0.0",
"description": "Barra de notificación superior con anuncio destacado, ícono y enlace de acción",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Mostrar barra de notificación",
"default": true,
"required": true,
"description": "Activa o desactiva la barra de notificación superior"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas",
"custom": "Páginas específicas"
},
"required": true,
"description": "Define en qué páginas se mostrará la barra"
},
"custom_page_ids": {
"type": "text",
"label": "IDs de páginas específicas",
"default": "",
"placeholder": "Ej: 1,5,10",
"conditional_logic": {
"field": "show_on_pages",
"operator": "==",
"value": "custom"
},
"description": "IDs de páginas separados por comas"
},
"hide_on_mobile": {
"type": "boolean",
"label": "Ocultar en dispositivos móviles",
"default": false,
"description": "Oculta la barra en pantallas menores a 768px"
},
"is_dismissible": {
"type": "boolean",
"label": "Permitir cerrar",
"default": false,
"description": "Agrega botón X para que el usuario pueda cerrar la barra"
},
"dismissible_cookie_days": {
"type": "number",
"label": "Días antes de volver a mostrar",
"default": 7,
"min": 1,
"max": 365,
"conditional_logic": {
"field": "is_dismissible",
"operator": "==",
"value": true
},
"description": "Días que permanece oculta después de cerrarla"
}
}
},
"content": {
"label": "Contenido",
"priority": 20,
"fields": {
"icon_type": {
"type": "select",
"label": "Tipo de ícono",
"default": "bootstrap",
"options": {
"bootstrap": "Bootstrap Icons",
"custom": "Imagen personalizada",
"none": "Sin ícono"
},
"required": true,
"description": "Selecciona el tipo de ícono a mostrar"
},
"bootstrap_icon": {
"type": "text",
"label": "Clase de ícono Bootstrap",
"default": "bi-megaphone-fill",
"placeholder": "Ej: bi-megaphone-fill",
"conditional_logic": {
"field": "icon_type",
"operator": "==",
"value": "bootstrap"
},
"required": true,
"description": "Nombre de la clase del ícono sin el prefijo 'bi' (ej: megaphone-fill)"
},
"custom_icon_url": {
"type": "media",
"label": "Imagen personalizada",
"default": "",
"media_type": "image",
"conditional_logic": {
"field": "icon_type",
"operator": "==",
"value": "custom"
},
"description": "Sube una imagen personalizada (recomendado: PNG 24x24px)"
},
"announcement_label": {
"type": "text",
"label": "Etiqueta del anuncio",
"default": "Nuevo:",
"placeholder": "Ej: Nuevo:, Importante:, Aviso:",
"maxlength": 30,
"required": true,
"description": "Texto destacado en negrita antes del mensaje"
},
"announcement_text": {
"type": "textarea",
"label": "Texto del anuncio",
"default": "Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.",
"maxlength": 200,
"rows": 3,
"required": true,
"description": "Mensaje principal del anuncio (máximo 200 caracteres)"
},
"link_enabled": {
"type": "boolean",
"label": "Mostrar enlace",
"default": true,
"description": "Activa o desactiva el enlace de acción"
},
"link_text": {
"type": "text",
"label": "Texto del enlace",
"default": "Ver Catálogo",
"maxlength": 50,
"conditional_logic": {
"field": "link_enabled",
"operator": "==",
"value": true
},
"required": true,
"description": "Texto del enlace de acción"
},
"link_url": {
"type": "url",
"label": "URL del enlace",
"default": "#",
"placeholder": "https://",
"conditional_logic": {
"field": "link_enabled",
"operator": "==",
"value": true
},
"required": true,
"description": "URL de destino del enlace"
},
"link_target": {
"type": "select",
"label": "Abrir enlace en",
"default": "_self",
"options": {
"_self": "Misma ventana",
"_blank": "Nueva ventana"
},
"conditional_logic": {
"field": "link_enabled",
"operator": "==",
"value": true
},
"description": "Define cómo se abrirá el enlace"
}
}
},
"styles": {
"label": "Estilos",
"priority": 30,
"fields": {
"background_color": {
"type": "color",
"label": "Color de fondo",
"default": "#FF8600",
"description": "Color de fondo de la barra (por defecto: orange primary)"
},
"text_color": {
"type": "color",
"label": "Color del texto",
"default": "#FFFFFF",
"description": "Color del texto del anuncio"
},
"link_color": {
"type": "color",
"label": "Color del enlace",
"default": "#FFFFFF",
"description": "Color del enlace de acción"
},
"font_size": {
"type": "select",
"label": "Tamaño de fuente",
"default": "small",
"options": {
"extra-small": "Muy pequeño (0.75rem)",
"small": "Pequeño (0.875rem)",
"normal": "Normal (1rem)",
"large": "Grande (1.125rem)"
},
"description": "Tamaño del texto del anuncio"
},
"padding_vertical": {
"type": "select",
"label": "Padding vertical",
"default": "normal",
"options": {
"compact": "Compacto (0.5rem)",
"normal": "Normal (0.75rem)",
"spacious": "Espacioso (1rem)"
},
"description": "Espaciado vertical interno de la barra"
},
"text_alignment": {
"type": "select",
"label": "Alineación del texto",
"default": "center",
"options": {
"left": "Izquierda",
"center": "Centro",
"right": "Derecha"
},
"description": "Alineación del contenido de la barra"
},
"animation_enabled": {
"type": "boolean",
"label": "Activar animación",
"default": false,
"description": "Activa animación de entrada al cargar la página"
},
"animation_type": {
"type": "select",
"label": "Tipo de animación",
"default": "slide-down",
"options": {
"slide-down": "Deslizar desde arriba",
"fade-in": "Aparecer gradualmente"
},
"conditional_logic": {
"field": "animation_enabled",
"operator": "==",
"value": true
},
"description": "Tipo de animación de entrada"
}
}
}
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace ROITheme\CTABelowContent\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class CTABelowContentFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder cta-below-content-form">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#content-tab" type="button">Contenido</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#button-tab" type="button">Botón</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#advanced-tab" type="button">Avanzado</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
<div class="tab-pane fade" id="content-tab"><?php echo $this->buildContentTab(); ?></div>
<div class="tab-pane fade" id="button-tab"><?php echo $this->buildButtonTab(); ?></div>
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
<div class="tab-pane fade" id="advanced-tab"><?php echo $this->buildAdvancedTab(); ?></div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildVisibilityTab(): string
{
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
$layout = $this->data['visibility']['layout'] ?? 'two-column';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
<label class="form-check-label" for="is_enabled"><strong>Activar CTA debajo del contenido</strong></label>
</div>
<small class="form-text text-muted">Muestra u oculta el componente de Call to Action</small>
</div>
<div class="form-group mb-4">
<label for="layout"><strong>Layout del CTA</strong></label>
<select class="form-select" id="layout" name="visibility[layout]">
<option value="two-column" <?php selected($layout, 'two-column'); ?>>Dos columnas (texto izquierda, botón derecha)</option>
<option value="centered" <?php selected($layout, 'centered'); ?>>Centrado</option>
<option value="stacked" <?php selected($layout, 'stacked'); ?>>Apilado</option>
</select>
<small class="form-text text-muted">Distribución del contenido en el CTA</small>
</div>
<?php
return ob_get_clean();
}
private function buildContentTab(): string
{
$title = $this->data['content']['title'] ?? 'Accede a 200,000+ Análisis de Precios Unitarios';
$subtitle = $this->data['content']['subtitle'] ?? 'Consulta estructuras completas, insumos y dosificaciones de los APUs más utilizados en construcción en México.';
ob_start();
?>
<div class="form-group mb-4">
<label for="title"><strong>Título</strong></label>
<input type="text" class="form-control" id="title" name="content[title]" value="<?php echo esc_attr($title); ?>" maxlength="200" required>
<small class="form-text text-muted">Título principal del CTA (máximo 200 caracteres)</small>
</div>
<div class="form-group mb-4">
<label for="subtitle"><strong>Subtítulo</strong></label>
<textarea class="form-control" id="subtitle" name="content[subtitle]" rows="3" maxlength="500" required><?php echo esc_textarea($subtitle); ?></textarea>
<small class="form-text text-muted">Texto descriptivo (máximo 500 caracteres)</small>
</div>
<?php
return ob_get_clean();
}
private function buildButtonTab(): string
{
$buttonText = $this->data['button']['button_text'] ?? 'Ver Catálogo Completo';
$buttonUrl = $this->data['button']['button_url'] ?? '#';
$buttonTarget = $this->data['button']['button_target'] ?? '_self';
$buttonColor = $this->data['button']['button_color'] ?? 'light';
$buttonSize = $this->data['button']['button_size'] ?? 'lg';
$showIcon = $this->data['button']['show_icon'] ?? true;
$iconClass = $this->data['button']['icon_class'] ?? 'bi bi-arrow-right';
ob_start();
?>
<div class="form-group mb-4">
<label for="button_text"><strong>Texto del botón</strong></label>
<input type="text" class="form-control" id="button_text" name="button[button_text]" value="<?php echo esc_attr($buttonText); ?>" maxlength="100" required>
</div>
<div class="form-group mb-4">
<label for="button_url"><strong>URL del botón</strong></label>
<input type="url" class="form-control" id="button_url" name="button[button_url]" value="<?php echo esc_url($buttonUrl); ?>" required>
<small class="form-text text-muted">URL de destino al hacer clic en el botón</small>
</div>
<div class="form-group mb-4">
<label for="button_target"><strong>Abrir enlace en</strong></label>
<select class="form-select" id="button_target" name="button[button_target]">
<option value="_self" <?php selected($buttonTarget, '_self'); ?>>Misma ventana</option>
<option value="_blank" <?php selected($buttonTarget, '_blank'); ?>>Nueva ventana</option>
</select>
</div>
<div class="form-group mb-4">
<label for="button_color"><strong>Color del botón</strong></label>
<select class="form-select" id="button_color" name="button[button_color]">
<option value="light" <?php selected($buttonColor, 'light'); ?>>Blanco</option>
<option value="dark" <?php selected($buttonColor, 'dark'); ?>>Negro</option>
<option value="primary" <?php selected($buttonColor, 'primary'); ?>>Azul</option>
<option value="success" <?php selected($buttonColor, 'success'); ?>>Verde</option>
<option value="danger" <?php selected($buttonColor, 'danger'); ?>>Rojo</option>
<option value="warning" <?php selected($buttonColor, 'warning'); ?>>Amarillo</option>
</select>
</div>
<div class="form-group mb-4">
<label for="button_size"><strong>Tamaño del botón</strong></label>
<select class="form-select" id="button_size" name="button[button_size]">
<option value="sm" <?php selected($buttonSize, 'sm'); ?>>Pequeño</option>
<option value="md" <?php selected($buttonSize, 'md'); ?>>Mediano</option>
<option value="lg" <?php selected($buttonSize, 'lg'); ?>>Grande</option>
</select>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_icon" name="button[show_icon]" value="1" <?php checked($showIcon, true); ?>>
<label class="form-check-label" for="show_icon">Mostrar icono de flecha en el botón</label>
</div>
</div>
<div class="form-group mb-4 conditional-field" data-depends-on="show_icon" data-condition="checked">
<label for="icon_class">Clase del icono (Bootstrap Icons)</label>
<input type="text" class="form-control" id="icon_class" name="button[icon_class]" value="<?php echo esc_attr($iconClass); ?>">
<small class="form-text text-muted">Ejemplo: bi bi-arrow-right, bi bi-chevron-right</small>
</div>
<?php
return ob_get_clean();
}
private function buildStylesTab(): string
{
$gradientStart = $this->data['styles']['gradient_start_color'] ?? '#FF8600';
$gradientEnd = $this->data['styles']['gradient_end_color'] ?? '#FFB800';
$gradientAngle = $this->data['styles']['gradient_angle'] ?? 135;
$textColor = $this->data['styles']['text_color'] ?? '#FFFFFF';
$containerClasses = $this->data['styles']['container_classes'] ?? 'my-5 p-4 rounded cta-section';
ob_start();
?>
<div class="form-group mb-4">
<label for="gradient_start_color"><strong>Color de inicio del gradiente</strong></label>
<div class="d-flex gap-2">
<input type="color" class="form-control form-control-color" id="gradient_start_color" name="styles[gradient_start_color]" value="<?php echo esc_attr($gradientStart); ?>">
<input type="text" class="form-control" value="<?php echo esc_attr($gradientStart); ?>" readonly>
</div>
</div>
<div class="form-group mb-4">
<label for="gradient_end_color"><strong>Color de fin del gradiente</strong></label>
<div class="d-flex gap-2">
<input type="color" class="form-control form-control-color" id="gradient_end_color" name="styles[gradient_end_color]" value="<?php echo esc_attr($gradientEnd); ?>">
<input type="text" class="form-control" value="<?php echo esc_attr($gradientEnd); ?>" readonly>
</div>
</div>
<div class="form-group mb-4">
<label for="gradient_angle"><strong>Ángulo del gradiente</strong></label>
<input type="range" class="form-range" id="gradient_angle" name="styles[gradient_angle]" min="0" max="360" value="<?php echo esc_attr($gradientAngle); ?>" oninput="this.nextElementSibling.value = this.value + '°'">
<output><?php echo esc_html($gradientAngle); ?>°</output>
<small class="form-text text-muted d-block">Dirección del gradiente en grados (0-360)</small>
</div>
<div class="form-group mb-4">
<label for="text_color"><strong>Color del texto</strong></label>
<div class="d-flex gap-2">
<input type="color" class="form-control form-control-color" id="text_color" name="styles[text_color]" value="<?php echo esc_attr($textColor); ?>">
<input type="text" class="form-control" value="<?php echo esc_attr($textColor); ?>" readonly>
</div>
</div>
<div class="form-group mb-4">
<label for="container_classes"><strong>Clases CSS del contenedor</strong></label>
<input type="text" class="form-control" id="container_classes" name="styles[container_classes]" value="<?php echo esc_attr($containerClasses); ?>">
<small class="form-text text-muted">Clases CSS adicionales para el contenedor principal</small>
</div>
<div class="alert alert-info">
<strong>Vista previa del gradiente:</strong>
<div style="background: linear-gradient(<?php echo esc_attr($gradientAngle); ?>deg, <?php echo esc_attr($gradientStart); ?> 0%, <?php echo esc_attr($gradientEnd); ?> 100%); padding: 20px; border-radius: 8px; margin-top: 10px; color: <?php echo esc_attr($textColor); ?>;">
Vista previa del gradiente con el color de texto seleccionado
</div>
</div>
<?php
return ob_get_clean();
}
private function buildAdvancedTab(): string
{
$animationEnabled = $this->data['advanced']['animation_enabled'] ?? false;
$animationType = $this->data['advanced']['animation_type'] ?? 'fade-in';
$animationDuration = $this->data['advanced']['animation_duration'] ?? 500;
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="animation_enabled" name="advanced[animation_enabled]" value="1" <?php checked($animationEnabled, true); ?>>
<label class="form-check-label" for="animation_enabled"><strong>Activar animación de entrada</strong></label>
</div>
<small class="form-text text-muted">Activa animaciones de entrada para hacer el CTA más llamativo</small>
</div>
<div class="form-group mb-4 conditional-field" data-depends-on="animation_enabled" data-condition="checked">
<label for="animation_type"><strong>Tipo de animación</strong></label>
<select class="form-select" id="animation_type" name="advanced[animation_type]">
<option value="fade-in" <?php selected($animationType, 'fade-in'); ?>>Fade In</option>
<option value="slide-up" <?php selected($animationType, 'slide-up'); ?>>Slide Up</option>
<option value="scale" <?php selected($animationType, 'scale'); ?>>Scale</option>
</select>
</div>
<div class="form-group mb-4 conditional-field" data-depends-on="animation_enabled" data-condition="checked">
<label for="animation_duration"><strong>Duración de animación (ms)</strong></label>
<input type="number" class="form-control" id="animation_duration" name="advanced[animation_duration]" value="<?php echo esc_attr($animationDuration); ?>" min="100" max="3000" step="50">
</div>
<div class="alert alert-info">
<h6>Notas:</h6>
<ul class="mb-0">
<li><strong>Layout:</strong> Elige cómo se distribuyen los elementos (dos columnas, centrado o apilado)</li>
<li><strong>Gradiente:</strong> Personaliza los colores y ángulo del gradiente de fondo</li>
<li><strong>Botón:</strong> Configura el texto, URL, color y tamaño del botón de acción</li>
<li><strong>Animación:</strong> Activa animaciones de entrada para hacer el CTA más llamativo</li>
</ul>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace ROITheme\CTABelowContent\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class CTABelowContentRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$layout = $data['visibility']['layout'] ?? 'two-column';
$gradient = $this->buildGradientStyle($data);
$containerClasses = $data['styles']['container_classes'] ?? 'my-5 p-4 rounded cta-section';
$animationAttrs = '';
if ($this->isAnimationEnabled($data)) {
$animationAttrs = $this->getAnimationAttributes($data);
}
$html = sprintf(
'<div class="%s" style="background: %s;"%s>',
esc_attr($containerClasses),
esc_attr($gradient),
$animationAttrs
);
$html .= $this->renderLayout($data, $layout);
$html .= '</div>';
if ($this->isAnimationEnabled($data)) {
$html .= $this->renderAnimationScript($data);
}
return $html;
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function isAnimationEnabled(array $data): bool
{
return isset($data['advanced']['animation_enabled']) &&
$data['advanced']['animation_enabled'] === true;
}
private function renderLayout(array $data, string $layout): string
{
switch ($layout) {
case 'centered':
return $this->renderCenteredLayout($data);
case 'stacked':
return $this->renderStackedLayout($data);
case 'two-column':
default:
return $this->renderTwoColumnLayout($data);
}
}
private function renderTwoColumnLayout(array $data): string
{
return sprintf(
'<div class="row align-items-center">
<div class="col-md-8">
%s
%s
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
%s
</div>
</div>',
$this->renderTitle($data),
$this->renderSubtitle($data, 'mb-md-0'),
$this->renderButton($data)
);
}
private function renderCenteredLayout(array $data): string
{
return sprintf(
'<div class="text-center">
%s
%s
<div class="mt-4">
%s
</div>
</div>',
$this->renderTitle($data),
$this->renderSubtitle($data, 'mb-3'),
$this->renderButton($data)
);
}
private function renderStackedLayout(array $data): string
{
return sprintf(
'<div>
%s
%s
<div class="mt-3">
%s
</div>
</div>',
$this->renderTitle($data),
$this->renderSubtitle($data, 'mb-3'),
$this->renderButton($data)
);
}
private function renderTitle(array $data): string
{
$title = $data['content']['title'] ?? 'Accede a 200,000+ Análisis de Precios Unitarios';
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
return sprintf(
'<h3 class="h4 fw-bold mb-2" style="color: %s;">%s</h3>',
esc_attr($textColor),
esc_html($title)
);
}
private function renderSubtitle(array $data, string $marginClass): string
{
$subtitle = $data['content']['subtitle'] ?? 'Consulta estructuras completas, insumos y dosificaciones de los APUs más utilizados en construcción en México.';
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
return sprintf(
'<p class="%s" style="color: %s;">%s</p>',
esc_attr($marginClass),
esc_attr($textColor),
esc_html($subtitle)
);
}
private function renderButton(array $data): string
{
$buttonText = $data['button']['button_text'] ?? 'Ver Catálogo Completo';
$buttonUrl = $data['button']['button_url'] ?? '#';
$buttonColor = $data['button']['button_color'] ?? 'light';
$buttonSize = $data['button']['button_size'] ?? 'lg';
$buttonTarget = $data['button']['button_target'] ?? '_self';
$showIcon = $data['button']['show_icon'] ?? true;
$iconClass = $data['button']['icon_class'] ?? 'bi bi-arrow-right';
$buttonClass = sprintf('btn btn-%s btn-%s cta-button', $buttonColor, $buttonSize);
$rel = $buttonTarget === '_blank' ? ' rel="noopener noreferrer"' : '';
$iconHtml = '';
if ($showIcon) {
$iconHtml = sprintf(' <i class="%s ms-2"></i>', esc_attr($iconClass));
}
return sprintf(
'<a href="%s" class="%s" target="%s"%s>%s%s</a>',
esc_url($buttonUrl),
esc_attr($buttonClass),
esc_attr($buttonTarget),
$rel,
esc_html($buttonText),
$iconHtml
);
}
private function buildGradientStyle(array $data): string
{
$start = $data['styles']['gradient_start_color'] ?? '#FF8600';
$end = $data['styles']['gradient_end_color'] ?? '#FFB800';
$angle = $data['styles']['gradient_angle'] ?? 135;
return sprintf('linear-gradient(%ddeg, %s 0%%, %s 100%%)', $angle, $start, $end);
}
private function getAnimationAttributes(array $data): string
{
$type = $data['advanced']['animation_type'] ?? 'fade-in';
$duration = $data['advanced']['animation_duration'] ?? 500;
return sprintf(' data-animation="%s" data-animation-duration="%d"', esc_attr($type), (int)$duration);
}
private function renderAnimationScript(array $data): string
{
$type = $data['advanced']['animation_type'] ?? 'fade-in';
return <<<SCRIPT
<script>
(function() {
const ctaElement = document.querySelector('.cta-section[data-animation]');
if (!ctaElement) return;
const animationType = ctaElement.getAttribute('data-animation');
const animationDuration = parseInt(ctaElement.getAttribute('data-animation-duration')) || 500;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
switch(animationType) {
case 'fade-in':
ctaElement.style.opacity = '0';
ctaElement.style.transition = `opacity \${animationDuration}ms ease`;
setTimeout(() => ctaElement.style.opacity = '1', 10);
break;
case 'slide-up':
ctaElement.style.transform = 'translateY(50px)';
ctaElement.style.opacity = '0';
ctaElement.style.transition = `transform \${animationDuration}ms ease, opacity \${animationDuration}ms ease`;
setTimeout(() => {
ctaElement.style.transform = 'translateY(0)';
ctaElement.style.opacity = '1';
}, 10);
break;
case 'scale':
ctaElement.style.transform = 'scale(0.9)';
ctaElement.style.opacity = '0';
ctaElement.style.transition = `transform \${animationDuration}ms ease, opacity \${animationDuration}ms ease`;
setTimeout(() => {
ctaElement.style.transform = 'scale(1)';
ctaElement.style.opacity = '1';
}, 10);
break;
}
observer.unobserve(ctaElement);
}
});
}, { threshold: 0.1 });
observer.observe(ctaElement);
})();
</script>
SCRIPT;
}
public function supports(string $componentType): bool
{
return $componentType === 'cta-below-content';
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace ROITheme\CTABoxSidebar\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class CTABoxSidebarFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder cta-box-form">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#content-tab" type="button">Contenido</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#button-tab" type="button">Botón</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#config-tab" type="button">Configuración</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="content-tab"><?php echo $this->buildContentTab(); ?></div>
<div class="tab-pane fade" id="button-tab"><?php echo $this->buildButtonTab(); ?></div>
<div class="tab-pane fade" id="config-tab"><?php echo $this->buildConfigTab(); ?></div>
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
</div>
</div>
<script>
jQuery(document).ready(function($) {
$('input[name="button[button_action]"]').on('change', function() {
$('.action-options').hide();
const action = $(this).val();
$('#' + action + '-options').show();
});
$('#button_icon').on('input', function() {
$('.icon-preview i').attr('class', 'bi ' + $(this).val());
});
$('.icon-option').on('click', function(e) {
e.preventDefault();
const icon = $(this).data('icon');
$('#button_icon').val(icon).trigger('input');
});
$('#use-gradient').on('change', function() {
if ($(this).is(':checked')) {
$('#solid-color-group').hide();
$('#gradient-color-group').show();
} else {
$('#solid-color-group').show();
$('#gradient-color-group').hide();
}
});
});
</script>
<?php
return ob_get_clean();
}
private function buildContentTab(): string
{
$title = $this->data['content']['title'] ?? '¿Listo para potenciar tus proyectos?';
$description = $this->data['content']['description'] ?? 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.';
ob_start();
?>
<div class="form-group mb-4">
<label for="title"><strong>Título</strong></label>
<input type="text" class="form-control" id="title" name="content[title]" value="<?php echo esc_attr($title); ?>" maxlength="100" required>
<small class="form-text text-muted">Título principal del CTA (máx. 100 caracteres)</small>
</div>
<div class="form-group mb-4">
<label for="description"><strong>Descripción</strong></label>
<textarea class="form-control" id="description" name="content[description]" rows="3" maxlength="200" required><?php echo esc_textarea($description); ?></textarea>
<small class="form-text text-muted">Texto descriptivo (máx. 200 caracteres)</small>
</div>
<?php
return ob_get_clean();
}
private function buildButtonTab(): string
{
$buttonText = $this->data['button']['button_text'] ?? 'Solicitar Demo';
$buttonIcon = $this->data['button']['button_icon'] ?? 'bi-calendar-check';
$buttonAction = $this->data['button']['button_action'] ?? 'modal';
$modalTarget = $this->data['button']['modal_target'] ?? '#contactModal';
$linkUrl = $this->data['button']['link_url'] ?? '';
$linkTarget = $this->data['button']['link_target'] ?? '_self';
$customOnclick = $this->data['button']['custom_onclick'] ?? '';
ob_start();
?>
<div class="form-group mb-4">
<label for="button_text"><strong>Texto del botón</strong></label>
<input type="text" class="form-control" id="button_text" name="button[button_text]" value="<?php echo esc_attr($buttonText); ?>" maxlength="50" required>
<small class="form-text text-muted">Texto que aparece en el botón (máx. 50 caracteres)</small>
</div>
<div class="form-group mb-4">
<label for="button_icon"><strong>Ícono del botón</strong></label>
<div class="input-group">
<input type="text" class="form-control" id="button_icon" name="button[button_icon]" value="<?php echo esc_attr($buttonIcon); ?>" placeholder="bi-calendar-check">
<div class="input-group-text icon-preview">
<i class="bi <?php echo esc_attr($buttonIcon); ?>"></i>
</div>
</div>
<small class="form-text text-muted">
Clase de Bootstrap Icons. <a href="https://icons.getbootstrap.com/" target="_blank">Ver íconos</a>
</small>
<div class="mt-2">
<strong class="d-block mb-2">Íconos populares:</strong>
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-calendar-check">
<i class="bi bi-calendar-check"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-rocket-takeoff">
<i class="bi bi-rocket-takeoff"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-gift">
<i class="bi bi-gift"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-star-fill">
<i class="bi bi-star-fill"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-lightning-charge">
<i class="bi bi-lightning-charge"></i>
</button>
</div>
</div>
<div class="form-group mb-4">
<label><strong>Acción del botón</strong></label>
<div class="form-check">
<input class="form-check-input" type="radio" id="action_modal" name="button[button_action]" value="modal" <?php checked($buttonAction, 'modal'); ?>>
<label class="form-check-label" for="action_modal">Abrir Modal</label>
</div>
<div id="modal-options" class="action-options mt-2 ms-4" style="display: <?php echo $buttonAction === 'modal' ? 'block' : 'none'; ?>;">
<label for="modal_target">ID del modal</label>
<input type="text" class="form-control" id="modal_target" name="button[modal_target]" value="<?php echo esc_attr($modalTarget); ?>" placeholder="#contactModal">
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" id="action_link" name="button[button_action]" value="link" <?php checked($buttonAction, 'link'); ?>>
<label class="form-check-label" for="action_link">Ir a URL</label>
</div>
<div id="link-options" class="action-options mt-2 ms-4" style="display: <?php echo $buttonAction === 'link' ? 'block' : 'none'; ?>;">
<label for="link_url">URL de destino</label>
<input type="url" class="form-control mb-2" id="link_url" name="button[link_url]" value="<?php echo esc_url($linkUrl); ?>" placeholder="https://example.com">
<label for="link_target">Abrir en</label>
<select class="form-select" id="link_target" name="button[link_target]">
<option value="_self" <?php selected($linkTarget, '_self'); ?>>Misma pestaña</option>
<option value="_blank" <?php selected($linkTarget, '_blank'); ?>>Nueva pestaña</option>
</select>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" id="action_custom" name="button[button_action]" value="custom" <?php checked($buttonAction, 'custom'); ?>>
<label class="form-check-label" for="action_custom">JavaScript Personalizado</label>
</div>
<div id="custom-options" class="action-options mt-2 ms-4" style="display: <?php echo $buttonAction === 'custom' ? 'block' : 'none'; ?>;">
<label for="custom_onclick">Código JavaScript</label>
<textarea class="form-control" id="custom_onclick" name="button[custom_onclick]" rows="3" placeholder="alert('Hola mundo!');"><?php echo esc_textarea($customOnclick); ?></textarea>
<small class="form-text text-muted text-warning">Solo administradores pueden usar JavaScript personalizado</small>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildConfigTab(): string
{
$height = $this->data['config']['height'] ?? '250px';
$showOnMobile = $this->data['config']['show_on_mobile'] ?? true;
$customCssClass = $this->data['config']['custom_css_class'] ?? '';
ob_start();
?>
<div class="form-group mb-4">
<label for="height"><strong>Altura del CTA box</strong></label>
<input type="text" class="form-control" id="height" name="config[height]" value="<?php echo esc_attr($height); ?>">
<small class="form-text text-muted">Valor CSS válido (ej: 250px, 20rem)</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_on_mobile" name="config[show_on_mobile]" value="1" <?php checked($showOnMobile, true); ?>>
<label class="form-check-label" for="show_on_mobile">Mostrar en dispositivos móviles</label>
</div>
</div>
<div class="form-group mb-4">
<label for="custom_css_class"><strong>Clase CSS personalizada</strong></label>
<input type="text" class="form-control" id="custom_css_class" name="config[custom_css_class]" value="<?php echo esc_attr($customCssClass); ?>">
<small class="form-text text-muted">Opcional: clase CSS adicional</small>
</div>
<?php
return ob_get_clean();
}
private function buildStylesTab(): string
{
$backgroundGradient = $this->data['styles']['background_gradient'] ?? false;
$backgroundColor = $this->data['styles']['background_color'] ?? '#FF8600';
$gradientStart = $this->data['styles']['gradient_start'] ?? '#FF8600';
$gradientEnd = $this->data['styles']['gradient_end'] ?? '#FF6B00';
$titleColor = $this->data['styles']['title_color'] ?? '#ffffff';
$descriptionColor = $this->data['styles']['description_color'] ?? 'rgba(255, 255, 255, 0.95)';
$buttonBgColor = $this->data['styles']['button_bg_color'] ?? '#ffffff';
$buttonTextColor = $this->data['styles']['button_text_color'] ?? '#FF8600';
$buttonHoverBg = $this->data['styles']['button_hover_bg'] ?? '#0E2337';
$buttonHoverText = $this->data['styles']['button_hover_text'] ?? '#ffffff';
$shadowColor = $this->data['styles']['shadow_color'] ?? 'rgba(255, 133, 0, 0.2)';
$borderRadius = $this->data['styles']['border_radius'] ?? '8px';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="use-gradient" name="styles[background_gradient]" value="1" <?php checked($backgroundGradient, true); ?>>
<label class="form-check-label" for="use-gradient">Usar gradiente en lugar de color sólido</label>
</div>
</div>
<div id="solid-color-group" class="mb-4" style="display: <?php echo $backgroundGradient ? 'none' : 'block'; ?>;">
<label for="background_color"><strong>Color de fondo</strong></label>
<input type="color" class="form-control form-control-color" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($backgroundColor); ?>">
</div>
<div id="gradient-color-group" class="mb-4" style="display: <?php echo $backgroundGradient ? 'block' : 'none'; ?>;">
<div class="row">
<div class="col-md-6 mb-3">
<label for="gradient_start"><strong>Color inicial del gradiente</strong></label>
<input type="color" class="form-control form-control-color" id="gradient_start" name="styles[gradient_start]" value="<?php echo esc_attr($gradientStart); ?>">
</div>
<div class="col-md-6 mb-3">
<label for="gradient_end"><strong>Color final del gradiente</strong></label>
<input type="color" class="form-control form-control-color" id="gradient_end" name="styles[gradient_end]" value="<?php echo esc_attr($gradientEnd); ?>">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<label for="title_color"><strong>Color del título</strong></label>
<input type="color" class="form-control form-control-color" id="title_color" name="styles[title_color]" value="<?php echo esc_attr($titleColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="description_color"><strong>Color de la descripción</strong></label>
<input type="text" class="form-control" id="description_color" name="styles[description_color]" value="<?php echo esc_attr($descriptionColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="button_bg_color"><strong>Color de fondo del botón</strong></label>
<input type="color" class="form-control form-control-color" id="button_bg_color" name="styles[button_bg_color]" value="<?php echo esc_attr($buttonBgColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="button_text_color"><strong>Color del texto del botón</strong></label>
<input type="color" class="form-control form-control-color" id="button_text_color" name="styles[button_text_color]" value="<?php echo esc_attr($buttonTextColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="button_hover_bg"><strong>Color de fondo (hover)</strong></label>
<input type="color" class="form-control form-control-color" id="button_hover_bg" name="styles[button_hover_bg]" value="<?php echo esc_attr($buttonHoverBg); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="button_hover_text"><strong>Color del texto (hover)</strong></label>
<input type="color" class="form-control form-control-color" id="button_hover_text" name="styles[button_hover_text]" value="<?php echo esc_attr($buttonHoverText); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="shadow_color"><strong>Color de sombra</strong></label>
<input type="text" class="form-control" id="shadow_color" name="styles[shadow_color]" value="<?php echo esc_attr($shadowColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="border_radius"><strong>Radio del borde</strong></label>
<input type="text" class="form-control" id="border_radius" name="styles[border_radius]" value="<?php echo esc_attr($borderRadius); ?>">
</div>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace ROITheme\CTABoxSidebar\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class CTABoxSidebarRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
$title = $data['content']['title'] ?? '¿Listo para potenciar tus proyectos?';
$description = $data['content']['description'] ?? 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.';
$buttonText = $data['button']['button_text'] ?? 'Solicitar Demo';
$buttonIcon = $data['button']['button_icon'] ?? 'bi-calendar-check';
$buttonAction = $data['button']['button_action'] ?? 'modal';
$modalTarget = $data['button']['modal_target'] ?? '#contactModal';
$linkUrl = $data['button']['link_url'] ?? '#';
$linkTarget = $data['button']['link_target'] ?? '_self';
$customOnclick = $data['button']['custom_onclick'] ?? '';
$height = $data['config']['height'] ?? '250px';
$showOnMobile = $data['config']['show_on_mobile'] ?? true;
$customCssClass = $data['config']['custom_css_class'] ?? '';
$componentId = $component->getId();
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
$mobileClass = !$showOnMobile ? 'd-none d-lg-block' : '';
$buttonAttrs = $this->buildButtonAttributes($buttonAction, $modalTarget, $customOnclick);
ob_start();
?>
<?php if (!empty($customStyles)): ?>
<style>
<?php echo $customStyles; ?>
</style>
<?php endif; ?>
<div id="<?php echo esc_attr($componentId); ?>"
class="cta-box-sidebar <?php echo esc_attr($customCssClass . ' ' . $mobileClass); ?>"
style="height: <?php echo esc_attr($height); ?>;">
<h5 class="cta-box-title">
<?php echo esc_html($title); ?>
</h5>
<p class="cta-box-text">
<?php echo esc_html($description); ?>
</p>
<?php if ($buttonAction === 'link'): ?>
<a href="<?php echo esc_url($linkUrl); ?>"
target="<?php echo esc_attr($linkTarget); ?>"
class="btn btn-cta-box w-100"
<?php if ($linkTarget === '_blank'): ?>
rel="noopener noreferrer"
<?php endif; ?>>
<?php if ($buttonIcon): ?>
<i class="bi <?php echo esc_attr($buttonIcon); ?> me-2"></i>
<?php endif; ?>
<?php echo esc_html($buttonText); ?>
</a>
<?php else: ?>
<button type="button"
class="btn btn-cta-box w-100"
<?php echo $buttonAttrs; ?>>
<?php if ($buttonIcon): ?>
<i class="bi <?php echo esc_attr($buttonIcon); ?> me-2"></i>
<?php endif; ?>
<?php echo esc_html($buttonText); ?>
</button>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
private function buildButtonAttributes(string $action, string $modalTarget, string $customOnclick): string
{
$attrs = [];
switch ($action) {
case 'modal':
$attrs[] = 'data-bs-toggle="modal"';
$attrs[] = 'data-bs-target="' . esc_attr($modalTarget) . '"';
break;
case 'custom':
if (!empty($customOnclick)) {
$attrs[] = 'onclick="' . esc_attr($customOnclick) . '"';
}
break;
}
return implode(' ', $attrs);
}
private function generateCustomStyles(string $componentId, array $styles): string
{
if (empty($styles)) {
return '';
}
$css = [];
if (isset($styles['background_gradient']) && $styles['background_gradient']) {
$gradientStart = $styles['gradient_start'] ?? '#FF8600';
$gradientEnd = $styles['gradient_end'] ?? '#FF6B00';
$css[] = "#$componentId.cta-box-sidebar { background: linear-gradient(135deg, {$gradientStart} 0%, {$gradientEnd} 100%); }";
} elseif (isset($styles['background_color'])) {
$css[] = "#$componentId.cta-box-sidebar { background-color: {$styles['background_color']}; }";
}
if (isset($styles['shadow_color'])) {
$css[] = "#$componentId.cta-box-sidebar { box-shadow: 0 4px 12px {$styles['shadow_color']}; }";
}
if (isset($styles['border_radius'])) {
$css[] = "#$componentId.cta-box-sidebar { border-radius: {$styles['border_radius']}; }";
}
if (isset($styles['title_color'])) {
$css[] = "#$componentId .cta-box-title { color: {$styles['title_color']}; }";
}
if (isset($styles['description_color'])) {
$css[] = "#$componentId .cta-box-text { color: {$styles['description_color']}; }";
}
if (isset($styles['button_bg_color'])) {
$css[] = "#$componentId .btn-cta-box { background-color: {$styles['button_bg_color']}; }";
}
if (isset($styles['button_text_color'])) {
$css[] = "#$componentId .btn-cta-box { color: {$styles['button_text_color']}; }";
}
if (isset($styles['button_hover_bg'])) {
$css[] = "#$componentId .btn-cta-box:hover { background-color: {$styles['button_hover_bg']}; }";
}
if (isset($styles['button_hover_text'])) {
$css[] = "#$componentId .btn-cta-box:hover { color: {$styles['button_hover_text']}; }";
}
return implode("\n", $css);
}
public function supports(string $componentType): bool
{
return $componentType === 'cta-box-sidebar';
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\Contracts;
/**
* Cache Service Interface
*
* Defines the contract for caching services.
* Implementations will provide caching mechanisms (transients, object cache, etc).
*
* @package ROITheme\Application\Contracts
*/
interface CacheServiceInterface
{
/**
* Get cached value
*
* @param string $key Cache key
* @return mixed
*/
public function get(string $key): mixed;
/**
* Set cached value
*
* @param string $key Cache key
* @param mixed $value Value to cache
* @param int $expiration Expiration time in seconds
* @return bool
*/
public function set(string $key, mixed $value, int $expiration = 0): bool;
/**
* Delete cached value
*
* @param string $key Cache key
* @return bool
*/
public function delete(string $key): bool;
/**
* Flush all cache
*
* @return bool
*/
public function flush(): bool;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\Contracts;
/**
* Validation Service Interface
*
* Defines the contract for validation services.
* Implementations will provide validation logic for various data types.
*
* @package ROITheme\Application\Contracts
*/
interface ValidationServiceInterface
{
/**
* Validate data against rules
*
* @param array<string, mixed> $data Data to validate
* @param array<string, mixed> $rules Validation rules
* @return bool
*/
public function validate(array $data, array $rules): bool;
/**
* Get validation errors
*
* @return array<string, string>
*/
public function getErrors(): array;
}

View File

View File

@@ -0,0 +1,110 @@
# Capa de Aplicación
## 📋 Propósito
La **Capa de Aplicación** contiene los **Use Cases** (Casos de Uso) que orquestan la lógica de negocio de la aplicación. Actúa como punto de entrada coordinando el flujo entre la UI/API y la Capa de Dominio.
## 🎯 Responsabilidades
- ✅ Orquestar flujo de la aplicación
- ✅ Coordinar llamadas entre capas
- ✅ Transformar datos (DTOs)
- ✅ Manejar transacciones
- ❌ NO contiene lógica de negocio (eso es del Dominio)
- ❌ NO conoce detalles de implementación (solo interfaces)
## 📦 Estructura
```
Application/
├── UseCases/
│ ├── SaveComponent/
│ │ ├── SaveComponentUseCase.php (Orquestador)
│ │ ├── SaveComponentRequest.php (DTO entrada)
│ │ └── SaveComponentResponse.php (DTO salida)
│ ├── GetComponent/
│ ├── SyncSchema/
│ └── DeleteComponent/
└── README.md (este archivo)
```
## 🔄 Use Cases Disponibles
### 1. SaveComponent
Guardar configuración de un componente.
**Flujo**:
1. Validar datos contra schema
2. Sanitizar datos
3. Crear entidad de dominio
4. Persistir
5. Invalidar cache
**Uso**:
```php
$useCase = new SaveComponentUseCase($repository, $validator, $cache);
$request = new SaveComponentRequest('top_bar', $data);
$response = $useCase->execute($request);
if ($response->isSuccess()) {
// Éxito
}
```
### 2. GetComponent
Obtener configuración de un componente (con cache).
**Estrategia**: Cache-first (1 hora TTL)
### 3. SyncSchema
Sincronizar schemas desde JSON a BD.
**Operaciones**: Agregar, Actualizar, Eliminar componentes
### 4. DeleteComponent
Eliminar componente y su cache.
## 📐 Principios Arquitectónicos
### Regla de Dependencias
```
Application → Domain (Interfaces)
NO depende de Infrastructure
```
### Dependency Inversion
Los Use Cases dependen de **interfaces** (Domain), no implementaciones (Infrastructure):
```php
// ✅ CORRECTO
public function __construct(
private ComponentRepositoryInterface $repository // Interfaz
) {}
// ❌ INCORRECTO
public function __construct(
private WordPressComponentRepository $repository // Implementación concreta
) {}
```
## 🧪 Testing
Todos los Use Cases tienen tests unitarios usando **mocks** para dependencias:
```php
$repository = $this->createMock(ComponentRepositoryInterface::class);
$validator = $this->createMock(ValidationServiceInterface::class);
$useCase = new SaveComponentUseCase($repository, $validator, $cache);
```
**Cobertura esperada**: 90%+
## 🔗 Referencias
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- Domain Layer: `../Domain/README.md`
- Tests: `../../tests/Unit/Application/`

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\DeleteComponent;
/**
* DeleteComponentRequest - DTO para eliminar componente
*
* @package ROITheme\Application\UseCases\DeleteComponent
*/
final class DeleteComponentRequest
{
public function __construct(
private string $componentName
) {}
public function getComponentName(): string
{
return $this->componentName;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\DeleteComponent;
/**
* DeleteComponentResponse - DTO de respuesta para eliminación
*
* @package ROITheme\Application\UseCases\DeleteComponent
*/
final class DeleteComponentResponse
{
private function __construct(
private bool $success,
private ?string $message,
private ?string $error
) {}
public function isSuccess(): bool
{
return $this->success;
}
public function getMessage(): ?string
{
return $this->message;
}
public function getError(): ?string
{
return $this->error;
}
public static function success(string $message): self
{
return new self(true, $message, null);
}
public static function failure(string $error): self
{
return new self(false, null, $error);
}
public function toArray(): array
{
return [
'success' => $this->success,
'message' => $this->message,
'error' => $this->error
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\DeleteComponent;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
/**
* DeleteComponentUseCase - Eliminar componente
*
* RESPONSABILIDAD: Orquestar eliminación de componente
*
* FLUJO:
* 1. Verificar que existe
* 2. Eliminar de BD
* 3. Invalidar cache
* 4. Retornar confirmación
*
* @package ROITheme\Application\UseCases\DeleteComponent
*/
final class DeleteComponentUseCase
{
public function __construct(
private ComponentRepositoryInterface $repository,
private CacheServiceInterface $cache
) {}
public function execute(DeleteComponentRequest $request): DeleteComponentResponse
{
try {
$componentNameString = $request->getComponentName();
$componentName = new ComponentName($componentNameString);
// 1. Verificar que existe
$component = $this->repository->findByName($componentName);
if ($component === null) {
return DeleteComponentResponse::failure(
"Component '{$componentNameString}' not found"
);
}
// 2. Eliminar
$deleted = $this->repository->delete($componentName);
if (!$deleted) {
return DeleteComponentResponse::failure(
"Failed to delete component '{$componentNameString}'"
);
}
// 3. Invalidar cache
$this->cache->delete("component_{$componentNameString}");
// 4. Retornar éxito
return DeleteComponentResponse::success(
"Component '{$componentNameString}' deleted successfully"
);
} catch (\Exception $e) {
return DeleteComponentResponse::failure(
'Unexpected error: ' . $e->getMessage()
);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\GetComponent;
/**
* GetComponentRequest - DTO de entrada para obtener componente
*
* RESPONSABILIDAD: Encapsular el nombre del componente a obtener
*
* USO:
* ```php
* $request = new GetComponentRequest('top_bar');
* ```
*
* @package ROITheme\Application\UseCases\GetComponent
*/
final class GetComponentRequest
{
/**
* @param string $componentName Nombre del componente a obtener
*/
public function __construct(
private string $componentName
) {}
/**
* Obtener nombre del componente
*
* @return string
*/
public function getComponentName(): string
{
return $this->componentName;
}
/**
* Factory method: Crear desde string
*
* @param string $componentName
* @return self
*/
public static function fromString(string $componentName): self
{
return new self($componentName);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\GetComponent;
/**
* GetComponentResponse - DTO de salida para obtener componente
*
* RESPONSABILIDAD: Encapsular resultado de obtener un componente
*
* PATRÓN: Success/Failure
* - Éxito: success=true, data contiene el componente
* - Fallo: success=false, error contiene mensaje de error
*
* USO:
* ```php
* $response = GetComponentResponse::success($componentData);
* if ($response->isSuccess()) {
* $data = $response->getData();
* }
* ```
*
* @package ROITheme\Application\UseCases\GetComponent
*/
final class GetComponentResponse
{
/**
* Constructor privado - usar factory methods
*
* @param bool $success Indica si la operación fue exitosa
* @param mixed $data Datos del componente (solo si success=true)
* @param string|null $error Mensaje de error (solo si success=false)
*/
private function __construct(
private bool $success,
private mixed $data,
private ?string $error
) {}
/**
* Verificar si la operación fue exitosa
*
* @return bool
*/
public function isSuccess(): bool
{
return $this->success;
}
/**
* Obtener datos del componente
*
* Solo válido si isSuccess() === true
*
* @return mixed
*/
public function getData(): mixed
{
return $this->data;
}
/**
* Obtener mensaje de error
*
* Solo válido si isSuccess() === false
*
* @return string|null
*/
public function getError(): ?string
{
return $this->error;
}
/**
* Factory method: Crear respuesta exitosa
*
* @param mixed $data Datos del componente
* @return self
*/
public static function success(mixed $data): self
{
return new self(true, $data, null);
}
/**
* Factory method: Crear respuesta de fallo
*
* @param string $error Mensaje de error
* @return self
*/
public static function failure(string $error): self
{
return new self(false, null, $error);
}
/**
* Convertir a array para serialización
*
* @return array
*/
public function toArray(): array
{
return [
'success' => $this->success,
'data' => $this->data,
'error' => $this->error
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\GetComponent;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
use ROITheme\Component\Domain\Exceptions\ComponentNotFoundException;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
/**
* GetComponentUseCase - Caso de Uso para obtener componente
*
* RESPONSABILIDAD: Orquestar la lógica de obtener un componente
*
* FLUJO:
* 1. Intentar obtener del cache
* 2. Si no está en cache, obtener del repositorio
* 3. Si no existe, lanzar excepción
* 4. Guardar en cache
* 5. Retornar respuesta
*
* OPTIMIZACIÓN:
* - Cache-first strategy para reducir queries a BD
* - TTL de 1 hora en cache
*
* USO:
* ```php
* $useCase = new GetComponentUseCase($repository, $cache);
* $request = new GetComponentRequest('top_bar');
* $response = $useCase->execute($request);
* ```
*
* @package ROITheme\Application\UseCases\GetComponent
*/
final class GetComponentUseCase
{
private const CACHE_TTL = 3600; // 1 hora
/**
* @param ComponentRepositoryInterface $repository Repositorio de componentes
* @param CacheServiceInterface $cache Servicio de cache
*/
public function __construct(
private ComponentRepositoryInterface $repository,
private CacheServiceInterface $cache
) {}
/**
* Ejecutar Use Case
*
* @param GetComponentRequest $request Datos de entrada
* @return GetComponentResponse Respuesta (éxito o fallo)
*/
public function execute(GetComponentRequest $request): GetComponentResponse
{
try {
$componentNameString = $request->getComponentName();
$componentName = new ComponentName($componentNameString);
$cacheKey = $this->getCacheKey($componentNameString);
// 1. Intentar obtener del cache
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return GetComponentResponse::success($cached);
}
// 2. Si no está en cache, obtener del repositorio
$component = $this->repository->findByName($componentName);
if ($component === null) {
throw new ComponentNotFoundException(
"Component '{$componentNameString}' not found"
);
}
$data = $component->toArray();
// 3. Guardar en cache
$this->cache->set($cacheKey, $data, self::CACHE_TTL);
// 4. Retornar respuesta exitosa
return GetComponentResponse::success($data);
} catch (ComponentNotFoundException $e) {
return GetComponentResponse::failure($e->getMessage());
} catch (\Exception $e) {
return GetComponentResponse::failure(
'Unexpected error: ' . $e->getMessage()
);
}
}
/**
* Generar key de cache para componente
*
* @param string $componentName
* @return string
*/
private function getCacheKey(string $componentName): string
{
return "component_{$componentName}";
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\SaveComponent;
/**
* SaveComponentRequest - DTO de entrada para guardar componente
*
* RESPONSABILIDAD: Encapsular los datos de entrada para el Use Case de guardar un componente
*
* CARACTERÍSTICAS:
* - Inmutable
* - Sin lógica de negocio
* - Validación básica de tipos (PHP hará type checking)
*
* USO:
* ```php
* $request = new SaveComponentRequest('top_bar', [
* 'configuration' => ['content' => ['message_text' => 'Welcome']],
* 'visibility' => ['desktop' => true, 'mobile' => true],
* 'is_enabled' => true
* ]);
* ```
*
* @package ROITheme\Application\UseCases\SaveComponent
*/
final class SaveComponentRequest
{
/**
* @param string $componentName Nombre del componente a guardar (e.g., 'top_bar', 'footer_cta')
* @param array $data Datos del componente (configuration, visibility, is_enabled, schema_version)
*/
public function __construct(
private string $componentName,
private array $data
) {}
/**
* Obtener nombre del componente
*
* @return string
*/
public function getComponentName(): string
{
return $this->componentName;
}
/**
* Obtener datos del componente
*
* @return array
*/
public function getData(): array
{
return $this->data;
}
/**
* Factory method: Crear desde array
*
* Útil para crear desde datos POST/JSON
*
* @param array $data Array con keys 'component_name' y 'data'
* @return self
*/
public static function fromArray(array $data): self
{
return new self(
$data['component_name'] ?? '',
$data['data'] ?? []
);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\SaveComponent;
/**
* SaveComponentResponse - DTO de salida para guardar componente
*
* RESPONSABILIDAD: Encapsular el resultado del Use Case (éxito o fallo)
*
* PATRÓN: Success/Failure
* - Éxito: success=true, data contiene el componente guardado
* - Fallo: success=false, errors contiene array de errores
*
* CARACTERÍSTICAS:
* - Inmutable
* - Constructor privado (usar factory methods)
* - Factory methods: success() y failure()
*
* USO:
* ```php
* // Éxito
* $response = SaveComponentResponse::success(['name' => 'top_bar', ...]);
* if ($response->isSuccess()) {
* $data = $response->getData();
* }
*
* // Fallo
* $response = SaveComponentResponse::failure(['Error de validación']);
* if (!$response->isSuccess()) {
* $errors = $response->getErrors();
* }
* ```
*
* @package ROITheme\Application\UseCases\SaveComponent
*/
final class SaveComponentResponse
{
/**
* Constructor privado - usar factory methods
*
* @param bool $success Indica si la operación fue exitosa
* @param mixed $data Datos del componente guardado (solo si success=true)
* @param array|null $errors Array de errores (solo si success=false)
*/
private function __construct(
private bool $success,
private mixed $data,
private ?array $errors
) {}
/**
* Verificar si la operación fue exitosa
*
* @return bool
*/
public function isSuccess(): bool
{
return $this->success;
}
/**
* Obtener datos del componente guardado
*
* Solo válido si isSuccess() === true
*
* @return mixed
*/
public function getData(): mixed
{
return $this->data;
}
/**
* Obtener errores
*
* Solo válido si isSuccess() === false
*
* @return array|null
*/
public function getErrors(): ?array
{
return $this->errors;
}
/**
* Factory method: Crear respuesta exitosa
*
* @param mixed $data Datos del componente guardado
* @return self
*/
public static function success(mixed $data): self
{
return new self(true, $data, null);
}
/**
* Factory method: Crear respuesta de fallo
*
* @param array $errors Array de mensajes de error
* @return self
*/
public static function failure(array $errors): self
{
return new self(false, null, $errors);
}
/**
* Convertir a array para serialización (JSON, etc.)
*
* @return array
*/
public function toArray(): array
{
return [
'success' => $this->success,
'data' => $this->data,
'errors' => $this->errors
];
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\SaveComponent;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Component\Domain\ValueObjects\ComponentVisibility;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\ValidationServiceInterface;
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
/**
* SaveComponentUseCase - Caso de Uso para guardar componente
*
* RESPONSABILIDAD: Orquestar la lógica de guardar un componente
*
* FLUJO:
* 1. Validar datos contra schema
* 2. Obtener datos sanitizados del resultado de validación
* 3. Crear entidad de dominio (Component)
* 4. Persistir en repositorio
* 5. Invalidar cache
* 6. Retornar respuesta
*
* PRINCIPIOS APLICADOS:
* - Single Responsibility: Solo orquesta, no tiene lógica de negocio
* - Dependency Inversion: Depende de interfaces, no implementaciones
* - Open/Closed: Extensible via cambio de implementaciones
*
* USO:
* ```php
* $useCase = new SaveComponentUseCase($repository, $validator, $cache);
* $request = new SaveComponentRequest('top_bar', $data);
* $response = $useCase->execute($request);
*
* if ($response->isSuccess()) {
* // Éxito
* }
* ```
*
* @package ROITheme\Application\UseCases\SaveComponent
*/
final class SaveComponentUseCase
{
/**
* @param ComponentRepositoryInterface $repository Repositorio de componentes
* @param ValidationServiceInterface $validator Servicio de validación
* @param CacheServiceInterface $cache Servicio de cache
*/
public function __construct(
private ComponentRepositoryInterface $repository,
private ValidationServiceInterface $validator,
private CacheServiceInterface $cache
) {}
/**
* Ejecutar Use Case
*
* @param SaveComponentRequest $request Datos de entrada
* @return SaveComponentResponse Respuesta (éxito o fallo)
*/
public function execute(SaveComponentRequest $request): SaveComponentResponse
{
try {
// 1. Validar datos contra schema
$validationResult = $this->validator->validate(
$request->getData(),
$request->getComponentName()
);
if (!$validationResult->isValid()) {
return SaveComponentResponse::failure($validationResult->getErrors());
}
// 2. Obtener datos sanitizados del resultado de validación
$sanitized = $validationResult->getSanitizedData();
// 3. Crear entidad de dominio
$component = $this->createComponent($request->getComponentName(), $sanitized);
// 4. Persistir (save() retorna Component guardado)
$savedComponent = $this->repository->save($component);
// 5. Invalidar cache
$this->cache->delete("component_{$request->getComponentName()}");
// 6. Retornar respuesta exitosa
return SaveComponentResponse::success($savedComponent->toArray());
} catch (InvalidComponentException $e) {
// Errores de dominio (validación de reglas de negocio)
return SaveComponentResponse::failure([$e->getMessage()]);
} catch (\Exception $e) {
// Errores inesperados
return SaveComponentResponse::failure([
'Unexpected error: ' . $e->getMessage()
]);
}
}
/**
* Crear entidad Component desde datos validados
*
* @param string $name Nombre del componente
* @param array $data Datos validados y sanitizados
* @return Component
* @throws InvalidComponentException Si los datos no cumplen invariantes
*/
private function createComponent(string $name, array $data): Component
{
// Extraer o usar valores por defecto
$isEnabled = $data['is_enabled'] ?? true;
$schemaVersion = $data['schema_version'] ?? '1.0.0';
// Extraer grupos de configuración (visibility, content, styles, general)
$configData = [
'visibility' => $data['visibility'] ?? [],
'content' => $data['content'] ?? [],
'styles' => $data['styles'] ?? [],
'general' => $data['general'] ?? []
];
// Los datos de visibility se manejan aparte en ComponentVisibility
$visibilityData = $data['visibility'] ?? [];
// Crear Value Objects
$componentName = new ComponentName($name);
$configuration = ComponentConfiguration::fromArray($configData);
$visibility = ComponentVisibility::fromArray($visibilityData);
// Crear entidad Component
return new Component(
$componentName,
$configuration,
$visibility,
$isEnabled,
$schemaVersion
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\SyncSchema;
/**
* SyncSchemaRequest - DTO de entrada para sincronizar schemas
*
* RESPONSABILIDAD: Encapsular ruta del archivo JSON de schemas
*
* USO:
* ```php
* $request = new SyncSchemaRequest('/path/to/schemas.json');
* ```
*
* @package ROITheme\Application\UseCases\SyncSchema
*/
final class SyncSchemaRequest
{
/**
* @param string $schemaFilePath Ruta al archivo JSON de schemas
*/
public function __construct(
private string $schemaFilePath
) {}
/**
* Obtener ruta del archivo de schemas
*
* @return string
*/
public function getSchemaFilePath(): string
{
return $this->schemaFilePath;
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\SyncSchema;
/**
* SyncSchemaResponse - DTO de salida para sincronizar schemas
*
* RESPONSABILIDAD: Encapsular resultado de sincronización
*
* DATOS INCLUIDOS:
* - success: bool
* - componentsAdded: array de nombres agregados
* - componentsUpdated: array de nombres actualizados
* - componentsDeleted: array de nombres eliminados
* - errors: array de errores
*
* @package ROITheme\Application\UseCases\SyncSchema
*/
final class SyncSchemaResponse
{
/**
* @param bool $success Indica si la sincronización fue exitosa
* @param array $componentsAdded Componentes agregados
* @param array $componentsUpdated Componentes actualizados
* @param array $componentsDeleted Componentes eliminados
* @param array $errors Errores ocurridos
*/
private function __construct(
private bool $success,
private array $componentsAdded,
private array $componentsUpdated,
private array $componentsDeleted,
private array $errors
) {}
public function isSuccess(): bool
{
return $this->success;
}
public function getComponentsAdded(): array
{
return $this->componentsAdded;
}
public function getComponentsUpdated(): array
{
return $this->componentsUpdated;
}
public function getComponentsDeleted(): array
{
return $this->componentsDeleted;
}
public function getErrors(): array
{
return $this->errors;
}
/**
* Factory method: Sincronización exitosa
*/
public static function success(
array $added,
array $updated,
array $deleted
): self {
return new self(true, $added, $updated, $deleted, []);
}
/**
* Factory method: Sincronización con errores
*/
public static function failure(array $errors): self
{
return new self(false, [], [], [], $errors);
}
/**
* Convertir a array
*/
public function toArray(): array
{
return [
'success' => $this->success,
'components_added' => $this->componentsAdded,
'components_updated' => $this->componentsUpdated,
'components_deleted' => $this->componentsDeleted,
'errors' => $this->errors
];
}
/**
* Obtener resumen de cambios
*/
public function getSummary(): string
{
$added = count($this->componentsAdded);
$updated = count($this->componentsUpdated);
$deleted = count($this->componentsDeleted);
return sprintf(
'Added: %d, Updated: %d, Deleted: %d',
$added,
$updated,
$deleted
);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Application\UseCases\SyncSchema;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
/**
* SyncSchemaUseCase - Sincronizar schemas JSON → BD
*
* RESPONSABILIDAD: Orquestar sincronización de componentes desde archivo JSON
*
* FLUJO:
* 1. Leer archivo JSON
* 2. Validar estructura
* 3. Comparar con BD (agregar/actualizar/eliminar)
* 4. Persistir cambios
* 5. Retornar resumen
*
* @package ROITheme\Application\UseCases\SyncSchema
*/
final class SyncSchemaUseCase
{
public function __construct(
private ComponentRepositoryInterface $componentRepository,
private ComponentDefaultsRepositoryInterface $defaultsRepository
) {}
/**
* Ejecutar sincronización
*/
public function execute(SyncSchemaRequest $request): SyncSchemaResponse
{
try {
// 1. Leer y parsear JSON
$schemas = $this->readSchemas($request->getSchemaFilePath());
if (empty($schemas)) {
return SyncSchemaResponse::failure(['No schemas found in file']);
}
// 2. Obtener componentes actuales de BD
$currentComponents = $this->componentRepository->findAll();
$currentNames = array_map(
fn($c) => $c->name()->value(),
$currentComponents
);
$schemaNames = array_keys($schemas);
// 3. Determinar cambios
$toAdd = array_diff($schemaNames, $currentNames);
$toUpdate = array_intersect($schemaNames, $currentNames);
$toDelete = array_diff($currentNames, $schemaNames);
// 4. Aplicar cambios
$added = $this->addComponents($toAdd, $schemas);
$updated = $this->updateComponents($toUpdate, $schemas);
$deleted = $this->deleteComponents($toDelete);
// 5. Retornar resumen
return SyncSchemaResponse::success($added, $updated, $deleted);
} catch (\Exception $e) {
return SyncSchemaResponse::failure([$e->getMessage()]);
}
}
/**
* Leer schemas desde archivo JSON
*/
private function readSchemas(string $filePath): array
{
if (!file_exists($filePath)) {
throw new \RuntimeException("Schema file not found: {$filePath}");
}
$content = file_get_contents($filePath);
$schemas = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON: ' . json_last_error_msg());
}
return $schemas;
}
/**
* Agregar nuevos componentes
*/
private function addComponents(array $names, array $schemas): array
{
$added = [];
foreach ($names as $name) {
$schema = $schemas[$name];
$componentName = new ComponentName($name);
$configuration = ComponentConfiguration::fromArray($schema);
$this->defaultsRepository->save($componentName, $configuration);
$added[] = $name;
}
return $added;
}
/**
* Actualizar componentes existentes
*/
private function updateComponents(array $names, array $schemas): array
{
$updated = [];
foreach ($names as $name) {
$schema = $schemas[$name];
$componentName = new ComponentName($name);
$configuration = ComponentConfiguration::fromArray($schema);
$this->defaultsRepository->save($componentName, $configuration);
$updated[] = $name;
}
return $updated;
}
/**
* Eliminar componentes obsoletos
*/
private function deleteComponents(array $names): array
{
$deleted = [];
foreach ($names as $name) {
$this->defaultsRepository->deleteDefaults($name);
$deleted[] = $name;
}
return $deleted;
}
}

View File

@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Component\Domain\ValueObjects\ComponentVisibility;
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
/**
* Component - Entidad del Dominio
*
* RESPONSABILIDAD: Representar un componente del tema con toda su lógica de negocio
*
* INVARIANTES (Reglas que SIEMPRE deben cumplirse):
* 1. Un componente siempre tiene un nombre válido
* 2. Un componente siempre tiene configuración (puede estar vacía)
* 3. Si tiene CTA URL debe tener CTA text (validado en ComponentConfiguration)
* 4. Los colores siempre están en formato hexadecimal válido
* 5. El timestamp de updated_at es siempre >= created_at
*
* ENTIDAD vs VALUE OBJECT:
* - Component es ENTIDAD porque tiene identidad (puede cambiar configuración pero sigue siendo el mismo componente)
* - ComponentName es VALUE OBJECT (dos nombres iguales son indistinguibles)
* - ComponentConfiguration es VALUE OBJECT (inmutable)
*
* USO:
* ```php
* $component = new Component(
* ComponentName::fromString('top_bar'),
* ComponentConfiguration::fromFlat([
* 'enabled' => true,
* 'message_text' => 'Welcome!'
* ]),
* ComponentVisibility::allDevices()
* );
*
* // Actualizar configuración (inmutabilidad)
* $updated = $component->updateConfiguration(
* $component->configuration()->withValue('content', 'message_text', 'Hello!')
* );
* ```
*
* @package ROITheme\Domain\Component
*/
final class Component
{
/**
* @var ComponentName Nombre del componente (inmutable)
*/
private ComponentName $name;
/**
* @var ComponentConfiguration Configuración del componente
*/
private ComponentConfiguration $configuration;
/**
* @var ComponentVisibility Visibilidad del componente
*/
private ComponentVisibility $visibility;
/**
* @var bool Componente habilitado
*/
private bool $isEnabled;
/**
* @var string Versión del schema
*/
private string $schemaVersion;
/**
* @var \DateTimeImmutable Fecha de creación
*/
private \DateTimeImmutable $createdAt;
/**
* @var \DateTimeImmutable Fecha de última actualización
*/
private \DateTimeImmutable $updatedAt;
/**
* Constructor
*
* @param ComponentName $name
* @param ComponentConfiguration $configuration
* @param ComponentVisibility $visibility
* @param bool $isEnabled
* @param string $schemaVersion
* @param \DateTimeImmutable|null $createdAt
* @param \DateTimeImmutable|null $updatedAt
* @throws InvalidComponentException Si se violan invariantes
*/
public function __construct(
ComponentName $name,
ComponentConfiguration $configuration,
ComponentVisibility $visibility,
bool $isEnabled = true,
string $schemaVersion = '1.0.0',
?\DateTimeImmutable $createdAt = null,
?\DateTimeImmutable $updatedAt = null
) {
$this->name = $name;
$this->configuration = $configuration;
$this->visibility = $visibility;
$this->isEnabled = $isEnabled;
$this->schemaVersion = $schemaVersion;
$this->createdAt = $createdAt ?? new \DateTimeImmutable();
$this->updatedAt = $updatedAt ?? new \DateTimeImmutable();
$this->validateInvariants();
}
/**
* Obtener nombre del componente
*
* @return ComponentName
*/
public function name(): ComponentName
{
return $this->name;
}
/**
* Obtener configuración del componente
*
* @return ComponentConfiguration
*/
public function configuration(): ComponentConfiguration
{
return $this->configuration;
}
/**
* Obtener visibilidad del componente
*
* @return ComponentVisibility
*/
public function visibility(): ComponentVisibility
{
return $this->visibility;
}
/**
* Verificar si el componente está habilitado
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->isEnabled;
}
/**
* Obtener versión del schema
*
* @return string
*/
public function schemaVersion(): string
{
return $this->schemaVersion;
}
/**
* Obtener fecha de creación
*
* @return \DateTimeImmutable
*/
public function createdAt(): \DateTimeImmutable
{
return $this->createdAt;
}
/**
* Obtener fecha de última actualización
*
* @return \DateTimeImmutable
*/
public function updatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
/**
* Crear nuevo componente con configuración actualizada
* (Inmutabilidad: retorna nueva instancia)
*
* @param ComponentConfiguration $configuration Nueva configuración
* @return self Nueva instancia
*/
public function updateConfiguration(ComponentConfiguration $configuration): self
{
return new self(
$this->name,
$configuration,
$this->visibility,
$this->isEnabled,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Crear nuevo componente con visibilidad actualizada
* (Inmutabilidad: retorna nueva instancia)
*
* @param ComponentVisibility $visibility Nueva visibilidad
* @return self Nueva instancia
*/
public function updateVisibility(ComponentVisibility $visibility): self
{
return new self(
$this->name,
$this->configuration,
$visibility,
$this->isEnabled,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Crear nuevo componente habilitado
* (Inmutabilidad: retorna nueva instancia)
*
* @return self Nueva instancia con isEnabled=true
*/
public function enable(): self
{
return new self(
$this->name,
$this->configuration,
$this->visibility,
true,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Crear nuevo componente deshabilitado
* (Inmutabilidad: retorna nueva instancia)
*
* @return self Nueva instancia con isEnabled=false
*/
public function disable(): self
{
return new self(
$this->name,
$this->configuration,
$this->visibility,
false,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Comparar con otro componente
* (Dos componentes son iguales si tienen el mismo nombre)
*
* @param Component $other Otro componente
* @return bool True si son el mismo componente
*/
public function equals(Component $other): bool
{
return $this->name->equals($other->name);
}
/**
* Convertir a array para serialización
*
* @return array
*/
public function toArray(): array
{
return [
'name' => $this->name->value(),
'configuration' => $this->configuration->all(),
'visibility' => $this->visibility->toArray(),
'is_enabled' => $this->isEnabled,
'schema_version' => $this->schemaVersion,
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt->format('Y-m-d H:i:s')
];
}
/**
* Convertir a string para debugging
*
* @return string
*/
public function __toString(): string
{
return sprintf(
'Component(%s, enabled=%s, schema=%s)',
$this->name->value(),
$this->isEnabled ? 'true' : 'false',
$this->schemaVersion
);
}
/**
* Validar invariantes del componente
*
* @throws InvalidComponentException Si se viola algún invariante
* @return void
*/
private function validateInvariants(): void
{
// Invariante: updated_at >= created_at
if ($this->updatedAt < $this->createdAt) {
throw new InvalidComponentException(
'Updated timestamp cannot be before created timestamp'
);
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace ROITheme\Domain\Component;
/**
* Component - Entidad del Dominio
*
* RESPONSABILIDAD: Representar un componente del tema con toda su configuración
*
* USO:
* ```php
* $component = new Component('hero-1', 'hero-section', [
* 'visibility' => ['is_enabled' => true],
* 'title' => ['text' => 'Welcome']
* ]);
*
* echo $component->getId(); // 'hero-1'
* echo $component->getType(); // 'hero-section'
* $data = $component->getData(); // array con configuración
* ```
*
* @package ROITheme\Domain\Component
*/
final class Component
{
private string $id;
private string $type;
private array $data;
/**
* Constructor
*
* @param string $id ID único del componente
* @param string $type Tipo de componente (ej: 'navbar', 'footer', 'hero-section')
* @param array $data Datos de configuración del componente
*/
public function __construct(string $id, string $type, array $data = [])
{
$this->id = $id;
$this->type = $type;
$this->data = $data;
}
/**
* Obtener ID del componente
*
* @return string
*/
public function getId(): string
{
return $this->id;
}
/**
* Obtener tipo de componente
*
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* Obtener datos de configuración
*
* @return array
*/
public function getData(): array
{
return $this->data;
}
/**
* Verificar si el componente está habilitado
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->data['visibility']['is_enabled'] ?? true;
}
/**
* Actualizar datos del componente
*
* @param array $data Nuevos datos
* @return self Nueva instancia con datos actualizados
*/
public function withData(array $data): self
{
return new self($this->id, $this->type, $data);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain;
/**
* Component Repository Interface
*
* Defines the contract for component persistence.
* This interface belongs to the Domain layer, but implementations will be in Infrastructure.
*
* @package ROITheme\Domain\Component
*/
interface ComponentRepositoryInterface
{
/**
* Save a component
*
* @param Component $component Component to save
* @return bool
*/
public function save(Component $component): bool;
/**
* Find component by ID
*
* @param int $id Component ID
* @return Component|null
*/
public function findById(int $id): ?Component;
/**
* Find all components
*
* @return array<Component>
*/
public function findAll(): array;
/**
* Delete a component
*
* @param int $id Component ID
* @return bool
*/
public function delete(int $id): bool;
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain\Exceptions;
/**
* ComponentNotFoundException - Excepción de Dominio para componente no encontrado
*
* RESPONSABILIDAD: Representar errores cuando un componente solicitado no existe
*
* CUÁNDO LANZAR:
* - Búsqueda de componente por nombre que no existe en el repositorio
* - Intento de actualizar/eliminar componente inexistente
* - Referencia a componente que fue eliminado
*
* USO:
* ```php
* $component = $repository->findByName($name);
*
* if ($component === null) {
* throw ComponentNotFoundException::withName($name);
* }
* ```
*
* @package ROITheme\Domain\Component\Exceptions
*/
class ComponentNotFoundException extends \RuntimeException
{
/**
* Constructor
*
* @param string $message Mensaje de error
* @param int $code Código de error (opcional)
* @param \Throwable|null $previous Excepción anterior (opcional)
*/
public function __construct(
string $message,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* Crear excepción para componente no encontrado por nombre
*
* @param string $componentName
* @return self
*/
public static function withName(string $componentName): self
{
return new self(
sprintf('Component "%s" not found', $componentName)
);
}
/**
* Crear excepción para componente no encontrado por ID
*
* @param int $componentId
* @return self
*/
public static function withId(int $componentId): self
{
return new self(
sprintf('Component with ID %d not found', $componentId)
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain\Exceptions;
/**
* InvalidComponentException - Excepción de Dominio para componentes inválidos
*
* RESPONSABILIDAD: Representar errores de validación de reglas de negocio de componentes
*
* CUÁNDO LANZAR:
* - Nombre de componente inválido (formato, longitud)
* - Configuración inválida (CTA URL sin CTA text, colores mal formateados)
* - Visibility inválida (combinaciones no permitidas)
* - Content inválido (URLs mal formateadas, textos vacíos cuando requeridos)
* - Cualquier violación de invariantes del dominio
*
* USO:
* ```php
* if (empty($name)) {
* throw new InvalidComponentException('Component name cannot be empty');
* }
* ```
*
* @package ROITheme\Domain\Component\Exceptions
*/
class InvalidComponentException extends \DomainException
{
/**
* Constructor
*
* @param string $message Mensaje de error
* @param int $code Código de error (opcional)
* @param \Throwable|null $previous Excepción anterior (opcional)
*/
public function __construct(
string $message,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* Crear excepción para nombre inválido
*
* @param string $name
* @param string $reason
* @return self
*/
public static function invalidName(string $name, string $reason): self
{
return new self(
sprintf('Invalid component name "%s": %s', $name, $reason)
);
}
/**
* Crear excepción para configuración inválida
*
* @param string $componentName
* @param string $reason
* @return self
*/
public static function invalidConfiguration(string $componentName, string $reason): self
{
return new self(
sprintf('Invalid configuration for component "%s": %s', $componentName, $reason)
);
}
/**
* Crear excepción para CTA inválido
*
* @param string $componentName
* @return self
*/
public static function ctaUrlRequiresText(string $componentName): self
{
return new self(
sprintf('Component "%s" has CTA URL but missing CTA text', $componentName)
);
}
/**
* Crear excepción para color inválido
*
* @param string $colorKey
* @param string $value
* @return self
*/
public static function invalidColor(string $colorKey, string $value): self
{
return new self(
sprintf('Invalid color format for "%s": "%s". Expected hexadecimal (e.g., #000000)', $colorKey, $value)
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain;
/**
* FormBuilderInterface - Contrato para constructores de formularios de componentes
*
* RESPONSABILIDAD: Definir el contrato que deben cumplir todos los constructores
* de formularios de administración para componentes.
*
* PRINCIPIOS:
* - Interface Segregation: Una sola responsabilidad - construir formularios
* - Dependency Inversion: Depender de abstracción, no de implementación
*
* USO:
* ```php
* final class MyFormBuilder implements FormBuilderInterface
* {
* private string $componentId;
* private array $data;
*
* public function __construct(string $componentId, array $data = [])
* {
* $this->componentId = $componentId;
* $this->data = $data;
* }
*
* public function build(): string
* {
* // Generar HTML del formulario
* return $html;
* }
*
* public function getComponentId(): string
* {
* return $this->componentId;
* }
* }
* ```
*
* @package ROITheme\Domain\Component
*/
interface FormBuilderInterface
{
/**
* Construir el HTML del formulario de configuración
*
* @return string HTML del formulario generado
*/
public function build(): string;
/**
* Obtener el ID del componente asociado
*
* @return string ID del componente
*/
public function getComponentId(): string;
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain;
/**
* RendererInterface - Contrato para renderizadores de componentes
*
* RESPONSABILIDAD: Definir el contrato que deben cumplir todos los renderizadores
* de componentes para generar HTML a partir de los datos del componente.
*
* PRINCIPIOS:
* - Interface Segregation: Una sola responsabilidad - renderizar HTML
* - Dependency Inversion: Depender de abstracción, no de implementación
*
* USO:
* ```php
* final class MyRenderer implements RendererInterface
* {
* public function render(Component $component): string
* {
* $data = $component->getData();
* // Generar HTML
* return $html;
* }
*
* public function supports(string $componentType): bool
* {
* return $componentType === 'my-component';
* }
* }
* ```
*
* @package ROITheme\Domain\Component
*/
interface RendererInterface
{
/**
* Renderizar un componente a HTML
*
* @param Component $component Componente a renderizar
* @return string HTML generado
*/
public function render(Component $component): string;
/**
* Verificar si este renderizador soporta un tipo de componente
*
* @param string $componentType Tipo de componente (ej: 'navbar', 'footer')
* @return bool True si soporta el tipo, false en caso contrario
*/
public function supports(string $componentType): bool;
}

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain\ValueObjects;
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
/**
* ComponentConfiguration - Value Object inmutable para configuración de componente
*
* RESPONSABILIDAD: Representar la configuración completa de un componente agrupada por categorías
*
* REGLAS DE NEGOCIO:
* - La configuración se organiza en grupos: visibility, content, styles, general
* - Cada grupo contiene pares clave-valor
* - Los valores pueden ser: string, boolean, integer, float, array
* - Si existe cta_url debe existir cta_text (y viceversa)
* - Los colores deben estar en formato hexadecimal válido
*
* INVARIANTES:
* - Una vez creada, la configuración no puede cambiar (inmutable)
* - La estructura de grupos siempre está presente (aunque vacía)
* - Los tipos de datos se preservan correctamente
*
* ESTRUCTURA:
* ```php
* [
* 'visibility' => [
* 'enabled' => true,
* 'visible_desktop' => true,
* 'visible_mobile' => false
* ],
* 'content' => [
* 'message_text' => 'Welcome!',
* 'cta_text' => 'Click here',
* 'cta_url' => 'https://example.com'
* ],
* 'styles' => [
* 'background_color' => '#000000',
* 'text_color' => '#ffffff',
* 'height' => 50
* ],
* 'general' => [
* 'priority' => 10,
* 'version' => '1.0.0'
* ]
* ]
* ```
*
* @package ROITheme\Domain\Component\ValueObjects
*/
final class ComponentConfiguration
{
/**
* Grupos de configuración válidos
*/
private const VALID_GROUPS = ['visibility', 'content', 'styles', 'general'];
/**
* @param array $configuration Configuración agrupada
* @throws InvalidComponentException
*/
public function __construct(private array $configuration)
{
$this->validate();
}
/**
* Obtener toda la configuración
*
* @return array
*/
public function all(): array
{
return $this->configuration;
}
/**
* Obtener configuración de un grupo específico
*
* @param string $group Nombre del grupo (visibility, content, styles, general)
* @return array
*/
public function getGroup(string $group): array
{
return $this->configuration[$group] ?? [];
}
/**
* Obtener valor de una clave específica dentro de un grupo
*
* @param string $group Nombre del grupo
* @param string $key Clave de configuración
* @param mixed $default Valor por defecto si no existe
* @return mixed
*/
public function get(string $group, string $key, mixed $default = null): mixed
{
return $this->configuration[$group][$key] ?? $default;
}
/**
* Verificar si existe una clave en un grupo
*
* @param string $group
* @param string $key
* @return bool
*/
public function has(string $group, string $key): bool
{
return isset($this->configuration[$group][$key]);
}
/**
* Verificar si el componente tiene CTA URL
*
* @return bool
*/
public function hasCTAURL(): bool
{
return $this->has('content', 'cta_url') && !empty($this->get('content', 'cta_url'));
}
/**
* Verificar si el componente tiene CTA text
*
* @return bool
*/
public function hasCTAText(): bool
{
return $this->has('content', 'cta_text') && !empty($this->get('content', 'cta_text'));
}
/**
* Verificar si el componente está habilitado
*
* @return bool
*/
public function isEnabled(): bool
{
return (bool) $this->get('visibility', 'enabled', false);
}
/**
* Crear nueva configuración con un valor actualizado
* (Inmutabilidad: retorna nueva instancia)
*
* @param string $group
* @param string $key
* @param mixed $value
* @return self
*/
public function withValue(string $group, string $key, mixed $value): self
{
$newConfiguration = $this->configuration;
if (!isset($newConfiguration[$group])) {
$newConfiguration[$group] = [];
}
$newConfiguration[$group][$key] = $value;
return new self($newConfiguration);
}
/**
* Crear nueva configuración con un grupo actualizado
* (Inmutabilidad: retorna nueva instancia)
*
* @param string $group
* @param array $values
* @return self
*/
public function withGroup(string $group, array $values): self
{
$newConfiguration = $this->configuration;
$newConfiguration[$group] = $values;
return new self($newConfiguration);
}
/**
* Validar reglas de negocio de la configuración
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla 1: Debe ser un array
if (!is_array($this->configuration)) {
throw new InvalidComponentException('Configuration must be an array');
}
// Regla 2: Los grupos deben ser válidos
foreach (array_keys($this->configuration) as $group) {
if (!in_array($group, self::VALID_GROUPS, true)) {
throw new InvalidComponentException(
sprintf(
'Invalid configuration group "%s". Valid groups are: %s',
$group,
implode(', ', self::VALID_GROUPS)
)
);
}
}
// Regla 3: Si existe cta_url debe existir cta_text (y viceversa)
if ($this->hasCTAURL() && !$this->hasCTAText()) {
throw new InvalidComponentException('CTA URL requires CTA text');
}
if ($this->hasCTAText() && !$this->hasCTAURL()) {
throw new InvalidComponentException('CTA text requires CTA URL');
}
// Regla 4: Los colores deben ser hexadecimales válidos
$this->validateColors();
}
/**
* Validar que los colores estén en formato hexadecimal
*
* @throws InvalidComponentException
* @return void
*/
private function validateColors(): void
{
$styles = $this->getGroup('styles');
foreach ($styles as $key => $value) {
if (str_ends_with($key, '_color') && !empty($value)) {
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
throw new InvalidComponentException(
sprintf(
'Color "%s" must be in hexadecimal format (e.g., #000000). Got: %s',
$key,
$value
)
);
}
}
}
}
/**
* Crear desde array flat (legacy)
*
* Convierte de:
* ```php
* [
* 'enabled' => true,
* 'message_text' => 'Hello',
* 'background_color' => '#000'
* ]
* ```
*
* A estructura agrupada.
*
* @param array $flatConfig
* @return self
*/
public static function fromFlat(array $flatConfig): self
{
$grouped = [
'visibility' => [],
'content' => [],
'styles' => [],
'general' => []
];
foreach ($flatConfig as $key => $value) {
$group = self::inferGroup($key);
$grouped[$group][$key] = $value;
}
return new self($grouped);
}
/**
* Crear desde array agrupado
*
* Espera estructura ya agrupada:
* ```php
* [
* 'visibility' => ['enabled' => true, ...],
* 'content' => ['message_text' => 'Hello', ...],
* 'styles' => ['background_color' => '#000', ...],
* 'general' => ['priority' => 10, ...]
* ]
* ```
*
* @param array $groupedConfig Configuración ya agrupada
* @return self
*/
public static function fromArray(array $groupedConfig): self
{
// Si ya está agrupado, usarlo directamente
return new self($groupedConfig);
}
/**
* Inferir grupo desde clave (heurística)
*
* @param string $key
* @return string
*/
private static function inferGroup(string $key): string
{
// Visibility
if (in_array($key, ['enabled', 'visible_desktop', 'visible_mobile', 'visible_tablet'], true)) {
return 'visibility';
}
// Content
if (str_starts_with($key, 'message_') ||
str_starts_with($key, 'cta_') ||
str_starts_with($key, 'title_')) {
return 'content';
}
// Styles
if (str_ends_with($key, '_color') ||
str_ends_with($key, '_height') ||
str_ends_with($key, '_width') ||
str_ends_with($key, '_size') ||
str_ends_with($key, '_font')) {
return 'styles';
}
// Fallback
return 'general';
}
/**
* Crear configuración vacía
*
* @return self
*/
public static function empty(): self
{
return new self([
'visibility' => [],
'content' => [],
'styles' => [],
'general' => []
]);
}
}

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain\ValueObjects;
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
/**
* ComponentContent - Value Object inmutable para contenido de componente
*
* RESPONSABILIDAD: Representar y validar el contenido textual y de CTA de un componente
*
* REGLAS DE NEGOCIO:
* - El mensaje de texto puede estar vacío (componentes sin texto)
* - Si existe CTA URL debe existir CTA text (y viceversa)
* - CTA URL debe ser una URL válida (formato http/https)
* - CTA text no puede estar vacío si existe CTA URL
* - Title es opcional
*
* CASOS DE USO:
* - Componente solo con mensaje de texto
* - Componente con mensaje y CTA
* - Componente con title, mensaje y CTA
* - Componente sin contenido textual (solo estilos/visibilidad)
*
* USO:
* ```php
* // Componente con mensaje y CTA
* $content = new ComponentContent(
* messageText: 'Welcome to our site!',
* ctaText: 'Learn More',
* ctaUrl: 'https://example.com/about'
* );
*
* // Componente solo con mensaje
* $content = ComponentContent::messageOnly('Hello World!');
*
* // Componente vacío
* $content = ComponentContent::empty();
* ```
*
* @package ROITheme\Domain\Component\ValueObjects
*/
final class ComponentContent
{
/**
* Constructor
*
* @param string $messageText Texto del mensaje principal
* @param string|null $ctaText Texto del Call-to-Action
* @param string|null $ctaUrl URL del Call-to-Action
* @param string|null $title Título del componente
* @throws InvalidComponentException
*/
public function __construct(
private string $messageText = '',
private ?string $ctaText = null,
private ?string $ctaUrl = null,
private ?string $title = null
) {
$this->validate();
}
/**
* Obtener mensaje de texto
*
* @return string
*/
public function messageText(): string
{
return $this->messageText;
}
/**
* Obtener texto del CTA
*
* @return string|null
*/
public function ctaText(): ?string
{
return $this->ctaText;
}
/**
* Obtener URL del CTA
*
* @return string|null
*/
public function ctaUrl(): ?string
{
return $this->ctaUrl;
}
/**
* Obtener título
*
* @return string|null
*/
public function title(): ?string
{
return $this->title;
}
/**
* Verificar si tiene mensaje de texto
*
* @return bool
*/
public function hasMessage(): bool
{
return !empty($this->messageText);
}
/**
* Verificar si tiene CTA
*
* @return bool
*/
public function hasCTA(): bool
{
return !empty($this->ctaText) && !empty($this->ctaUrl);
}
/**
* Verificar si tiene título
*
* @return bool
*/
public function hasTitle(): bool
{
return !empty($this->title);
}
/**
* Verificar si está completamente vacío
*
* @return bool
*/
public function isEmpty(): bool
{
return empty($this->messageText) &&
empty($this->ctaText) &&
empty($this->ctaUrl) &&
empty($this->title);
}
/**
* Convertir a array
*
* @return array
*/
public function toArray(): array
{
return array_filter([
'message_text' => $this->messageText,
'cta_text' => $this->ctaText,
'cta_url' => $this->ctaUrl,
'title' => $this->title
], fn($value) => $value !== null && $value !== '');
}
/**
* Validar reglas de negocio
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla 1: Si existe CTA URL debe existir CTA text
if (!empty($this->ctaUrl) && empty($this->ctaText)) {
throw new InvalidComponentException('CTA URL requires CTA text');
}
// Regla 2: Si existe CTA text debe existir CTA URL
if (!empty($this->ctaText) && empty($this->ctaUrl)) {
throw new InvalidComponentException('CTA text requires CTA URL');
}
// Regla 3: CTA URL debe ser URL válida
if (!empty($this->ctaUrl) && !$this->isValidUrl($this->ctaUrl)) {
throw new InvalidComponentException(
sprintf('Invalid CTA URL format: %s', $this->ctaUrl)
);
}
}
/**
* Validar si una string es URL válida
*
* @param string $url
* @return bool
*/
private function isValidUrl(string $url): bool
{
// Debe comenzar con http:// o https://
if (!preg_match('/^https?:\/\//i', $url)) {
return false;
}
// Usar filter_var para validación completa
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
/**
* Factory: Contenido vacío
*
* @return self
*/
public static function empty(): self
{
return new self();
}
/**
* Factory: Solo mensaje
*
* @param string $message
* @return self
*/
public static function messageOnly(string $message): self
{
return new self(messageText: $message);
}
/**
* Factory: Mensaje con CTA
*
* @param string $message
* @param string $ctaText
* @param string $ctaUrl
* @return self
*/
public static function withCTA(string $message, string $ctaText, string $ctaUrl): self
{
return new self(
messageText: $message,
ctaText: $ctaText,
ctaUrl: $ctaUrl
);
}
/**
* Crear desde array
*
* @param array $data
* @return self
*/
public static function fromArray(array $data): self
{
return new self(
messageText: $data['message_text'] ?? '',
ctaText: $data['cta_text'] ?? null,
ctaUrl: $data['cta_url'] ?? null,
title: $data['title'] ?? null
);
}
/**
* Comparar con otro ComponentContent
*
* @param ComponentContent $other
* @return bool
*/
public function equals(ComponentContent $other): bool
{
return $this->messageText === $other->messageText &&
$this->ctaText === $other->ctaText &&
$this->ctaUrl === $other->ctaUrl &&
$this->title === $other->title;
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain\ValueObjects;
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
/**
* ComponentName - Value Object inmutable para nombre de componente
*
* RESPONSABILIDAD: Representar y validar el nombre de un componente del tema
*
* REGLAS DE NEGOCIO:
* - El nombre no puede estar vacío
* - El nombre debe tener entre 3 y 50 caracteres
* - El nombre solo puede contener letras minúsculas, números y guiones bajos
* - El nombre debe comenzar con una letra
*
* INVARIANTES:
* - Una vez creado, el nombre no puede cambiar (inmutable)
* - El nombre siempre está en formato normalizado (lowercase, sin espacios)
*
* USO:
* ```php
* $name = new ComponentName('top_bar');
* echo $name->value(); // "top_bar"
* echo $name->toString(); // "top_bar"
* ```
*
* @package ROITheme\Domain\Component\ValueObjects
*/
final class ComponentName
{
/**
* Longitud mínima del nombre
*/
private const MIN_LENGTH = 3;
/**
* Longitud máxima del nombre
*/
private const MAX_LENGTH = 50;
/**
* Patrón regex para validar formato del nombre
* - Debe comenzar con letra minúscula
* - Puede contener letras minúsculas, números y guiones bajos
*/
private const PATTERN = '/^[a-z][a-z0-9_]*$/';
/**
* @param string $value Nombre del componente
* @throws InvalidComponentException Si el nombre no cumple las reglas de negocio
*/
public function __construct(private string $value)
{
$this->validate();
}
/**
* Obtener el valor del nombre
*
* @return string
*/
public function value(): string
{
return $this->value;
}
/**
* Convertir a string
*
* @return string
*/
public function toString(): string
{
return $this->value;
}
/**
* Comparar con otro ComponentName
*
* @param ComponentName $other
* @return bool
*/
public function equals(ComponentName $other): bool
{
return $this->value === $other->value;
}
/**
* Validar reglas de negocio del nombre
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla 1: No puede estar vacío
if (empty($this->value)) {
throw new InvalidComponentException('Component name cannot be empty');
}
// Regla 2: Longitud entre MIN y MAX
$length = strlen($this->value);
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw new InvalidComponentException(
sprintf(
'Component name must be between %d and %d characters (got %d)',
self::MIN_LENGTH,
self::MAX_LENGTH,
$length
)
);
}
// Regla 3: Formato válido (lowercase, números, guiones bajos, comienza con letra)
if (!preg_match(self::PATTERN, $this->value)) {
throw new InvalidComponentException(
'Component name must start with a letter and contain only lowercase letters, numbers, and underscores'
);
}
}
/**
* Crear desde string (factory method)
*
* @param string $value
* @return self
*/
public static function fromString(string $value): self
{
return new self($value);
}
/**
* Normalizar string a formato de nombre válido
*
* Convierte:
* - "Top Bar" → "top_bar"
* - "FOOTER-CTA" → "footer_cta"
* - " sidebar " → "sidebar"
*
* @param string $value
* @return self
*/
public static function fromNormalized(string $value): self
{
// Trim espacios
$normalized = trim($value);
// Convertir a minúsculas
$normalized = strtolower($normalized);
// Reemplazar espacios y guiones por guiones bajos
$normalized = str_replace([' ', '-'], '_', $normalized);
// Eliminar caracteres no permitidos
$normalized = preg_replace('/[^a-z0-9_]/', '', $normalized);
// Eliminar guiones bajos duplicados
$normalized = preg_replace('/_+/', '_', $normalized);
// Eliminar guiones bajos al inicio/final
$normalized = trim($normalized, '_');
return new self($normalized);
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Domain\ValueObjects;
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
/**
* ComponentVisibility - Value Object inmutable para visibilidad de componente
*
* RESPONSABILIDAD: Representar y validar la visibilidad de un componente en diferentes dispositivos
*
* REGLAS DE NEGOCIO:
* - Un componente puede estar habilitado o deshabilitado globalmente
* - Un componente puede tener visibilidad específica por dispositivo (desktop, tablet, mobile)
* - Si está deshabilitado globalmente, las visibilidades por dispositivo no importan
* - Al menos un dispositivo debe tener visibilidad si está habilitado globalmente
*
* CASOS DE USO:
* - Componente visible solo en desktop
* - Componente visible solo en mobile
* - Componente visible en todos los dispositivos
* - Componente completamente deshabilitado
*
* USO:
* ```php
* // Visible en todos los dispositivos
* $visibility = ComponentVisibility::allDevices();
*
* // Visible solo en desktop
* $visibility = ComponentVisibility::desktopOnly();
*
* // Personalizado
* $visibility = new ComponentVisibility(
* enabled: true,
* visibleDesktop: true,
* visibleTablet: true,
* visibleMobile: false
* );
* ```
*
* @package ROITheme\Domain\Component\ValueObjects
*/
final class ComponentVisibility
{
/**
* Constructor
*
* @param bool $enabled Componente habilitado globalmente
* @param bool $visibleDesktop Visible en desktop
* @param bool $visibleTablet Visible en tablet
* @param bool $visibleMobile Visible en mobile
* @throws InvalidComponentException
*/
public function __construct(
private bool $enabled,
private bool $visibleDesktop,
private bool $visibleTablet,
private bool $visibleMobile
) {
$this->validate();
}
/**
* Verificar si está habilitado globalmente
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Verificar si es visible en desktop
*
* @return bool
*/
public function isVisibleOnDesktop(): bool
{
return $this->enabled && $this->visibleDesktop;
}
/**
* Verificar si es visible en tablet
*
* @return bool
*/
public function isVisibleOnTablet(): bool
{
return $this->enabled && $this->visibleTablet;
}
/**
* Verificar si es visible en mobile
*
* @return bool
*/
public function isVisibleOnMobile(): bool
{
return $this->enabled && $this->visibleMobile;
}
/**
* Verificar si es visible en al menos un dispositivo
*
* @return bool
*/
public function isVisibleOnAnyDevice(): bool
{
return $this->enabled && (
$this->visibleDesktop ||
$this->visibleTablet ||
$this->visibleMobile
);
}
/**
* Verificar si es visible en todos los dispositivos
*
* @return bool
*/
public function isVisibleOnAllDevices(): bool
{
return $this->enabled &&
$this->visibleDesktop &&
$this->visibleTablet &&
$this->visibleMobile;
}
/**
* Convertir a array
*
* @return array
*/
public function toArray(): array
{
return [
'enabled' => $this->enabled,
'visible_desktop' => $this->visibleDesktop,
'visible_tablet' => $this->visibleTablet,
'visible_mobile' => $this->visibleMobile
];
}
/**
* Validar reglas de negocio
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla: Si está habilitado, al menos un dispositivo debe tener visibilidad
if ($this->enabled && !$this->visibleDesktop && !$this->visibleTablet && !$this->visibleMobile) {
throw new InvalidComponentException(
'Component is enabled but not visible on any device. At least one device must be visible.'
);
}
}
/**
* Factory: Componente deshabilitado
*
* @return self
*/
public static function disabled(): self
{
return new self(
enabled: false,
visibleDesktop: false,
visibleTablet: false,
visibleMobile: false
);
}
/**
* Factory: Componente visible en todos los dispositivos
*
* @return self
*/
public static function allDevices(): self
{
return new self(
enabled: true,
visibleDesktop: true,
visibleTablet: true,
visibleMobile: true
);
}
/**
* Factory: Componente visible solo en desktop
*
* @return self
*/
public static function desktopOnly(): self
{
return new self(
enabled: true,
visibleDesktop: true,
visibleTablet: false,
visibleMobile: false
);
}
/**
* Factory: Componente visible solo en mobile
*
* @return self
*/
public static function mobileOnly(): self
{
return new self(
enabled: true,
visibleDesktop: false,
visibleTablet: false,
visibleMobile: true
);
}
/**
* Factory: Componente visible solo en tablet
*
* @return self
*/
public static function tabletOnly(): self
{
return new self(
enabled: true,
visibleDesktop: false,
visibleTablet: true,
visibleMobile: false
);
}
/**
* Crear desde array
*
* @param array $data
* @return self
*/
public static function fromArray(array $data): self
{
return new self(
enabled: (bool) ($data['enabled'] ?? false),
visibleDesktop: (bool) ($data['visible_desktop'] ?? true),
visibleTablet: (bool) ($data['visible_tablet'] ?? true),
visibleMobile: (bool) ($data['visible_mobile'] ?? true)
);
}
/**
* Comparar con otro ComponentVisibility
*
* @param ComponentVisibility $other
* @return bool
*/
public function equals(ComponentVisibility $other): bool
{
return $this->enabled === $other->enabled &&
$this->visibleDesktop === $other->visibleDesktop &&
$this->visibleTablet === $other->visibleTablet &&
$this->visibleMobile === $other->visibleMobile;
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\API\WordPress;
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentUseCase;
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentRequest;
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentUseCase;
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentRequest;
use ROITheme\Component\Application\UseCases\DeleteComponent\DeleteComponentUseCase;
use ROITheme\Component\Application\UseCases\DeleteComponent\DeleteComponentRequest;
use ROITheme\Component\Infrastructure\DI\DIContainer;
/**
* AjaxController - Endpoints AJAX de WordPress
*
* RESPONSABILIDAD: Manejar HTTP (request/response)
*
* PRINCIPIOS:
* - Single Responsibility: Solo maneja HTTP, delega lógica a Use Cases
* - Dependency Inversion: Depende de Use Cases (Application)
*
* SEGURIDAD:
* - Verifica nonce
* - Verifica capabilities (manage_options)
* - Sanitiza inputs
*
* @package ROITheme\Infrastructure\API\WordPress
*/
final class AjaxController
{
private const NONCE_ACTION = 'roi_theme_admin_nonce';
public function __construct(
private DIContainer $container
) {}
/**
* Registrar hooks de WordPress
*/
public function register(): void
{
add_action('wp_ajax_roi_theme_save_component', [$this, 'saveComponent']);
add_action('wp_ajax_roi_theme_get_component', [$this, 'getComponent']);
add_action('wp_ajax_roi_theme_delete_component', [$this, 'deleteComponent']);
add_action('wp_ajax_roi_theme_sync_schema', [$this, 'syncSchema']);
}
/**
* Endpoint: Guardar componente
*
* POST /wp-admin/admin-ajax.php?action=roi_theme_save_component
* Body: { nonce, component_name, data }
*
* @return void (usa wp_send_json_*)
*/
public function saveComponent(): void
{
// 1. Seguridad
if (!$this->verifySecurity()) {
wp_send_json_error([
'message' => 'Security check failed'
], 403);
return;
}
// 2. Sanitizar input
$componentName = sanitize_key($_POST['component_name'] ?? '');
$data = $_POST['data'] ?? [];
if (empty($componentName)) {
wp_send_json_error([
'message' => 'Component name is required'
], 400);
return;
}
// 3. Crear Request DTO
$request = new SaveComponentRequest($componentName, $data);
// 4. Ejecutar Use Case
$useCase = new SaveComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getValidationService(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
// 5. Respuesta HTTP
if ($response->isSuccess()) {
wp_send_json_success($response->getData());
} else {
wp_send_json_error([
'message' => 'Validation failed',
'errors' => $response->getErrors()
], 422);
}
}
/**
* Endpoint: Obtener componente
*
* GET /wp-admin/admin-ajax.php?action=roi_theme_get_component&component_name=xxx
*/
public function getComponent(): void
{
if (!$this->verifySecurity()) {
wp_send_json_error(['message' => 'Security check failed'], 403);
return;
}
$componentName = sanitize_key($_GET['component_name'] ?? '');
if (empty($componentName)) {
wp_send_json_error(['message' => 'Component name is required'], 400);
return;
}
$request = new GetComponentRequest($componentName);
$useCase = new GetComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
if ($response->isSuccess()) {
wp_send_json_success($response->getData());
} else {
wp_send_json_error(['message' => $response->getError()], 404);
}
}
/**
* Endpoint: Eliminar componente
*
* POST /wp-admin/admin-ajax.php?action=roi_theme_delete_component
* Body: { nonce, component_name }
*/
public function deleteComponent(): void
{
if (!$this->verifySecurity()) {
wp_send_json_error(['message' => 'Security check failed'], 403);
return;
}
$componentName = sanitize_key($_POST['component_name'] ?? '');
if (empty($componentName)) {
wp_send_json_error(['message' => 'Component name is required'], 400);
return;
}
$request = new DeleteComponentRequest($componentName);
$useCase = new DeleteComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
if ($response->isSuccess()) {
wp_send_json_success(['message' => $response->getMessage()]);
} else {
wp_send_json_error(['message' => $response->getError()], 404);
}
}
/**
* Endpoint: Sincronizar schemas
*
* POST /wp-admin/admin-ajax.php?action=roi_theme_sync_schema
*/
public function syncSchema(): void
{
if (!$this->verifySecurity()) {
wp_send_json_error(['message' => 'Security check failed'], 403);
return;
}
$syncService = $this->container->getSchemaSyncService();
$result = $syncService->syncAll();
if ($result['success']) {
wp_send_json_success($result['data']);
} else {
wp_send_json_error(['message' => $result['error']], 500);
}
}
/**
* Verificar seguridad (nonce + capabilities)
*
* @return bool
*/
private function verifySecurity(): bool
{
// Verificar nonce
$nonce = $_REQUEST['nonce'] ?? '';
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
return false;
}
// Verificar permisos (solo administradores)
if (!current_user_can('manage_options')) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\API\WordPress;
use ROITheme\Component\Infrastructure\Persistence\WordPress\DatabaseMigrator;
/**
* WP-CLI Command para Migración de Base de Datos
*
* Responsabilidad: Interfaz CLI para ejecutar migración de BD
*
* COMANDOS DISPONIBLES:
* - wp roi-theme migrate : Ejecutar migración real
* - wp roi-theme migrate --dry-run : Simular migración sin cambios
* - wp roi-theme cleanup-backup : Eliminar tablas de backup
*
* FLUJO RECOMENDADO:
* 1. Crear backup de BD manualmente
* 2. Ejecutar: wp roi-theme migrate --dry-run (simulación)
* 3. Ejecutar: wp roi-theme migrate (migración real)
* 4. Validar funcionamiento por 7-30 días
* 5. Ejecutar: wp roi-theme cleanup-backup
*
* USO:
* ```bash
* # Dry-run (simulación)
* wp roi-theme migrate --dry-run
*
* # Migración real
* wp roi-theme migrate
*
* # Limpiar backup (después de validar)
* wp roi-theme cleanup-backup
* ```
*/
final class MigrationCommand
{
/**
* Ejecutar migración de BD legacy a Clean Architecture
*
* ## OPCIONES
*
* [--dry-run]
* : Solo mostrar qué se haría sin ejecutar cambios reales
*
* ## EJEMPLOS
*
* # Simular migración
* wp roi-theme migrate --dry-run
*
* # Ejecutar migración real
* wp roi-theme migrate
*
* @param array $args Argumentos posicionales
* @param array $assoc_args Argumentos asociativos (--flags)
* @return void
*/
public function migrate(array $args, array $assoc_args): void
{
global $wpdb;
$dry_run = isset($assoc_args['dry-run']);
if ($dry_run) {
\WP_CLI::line('');
\WP_CLI::line('🔍 MODO DRY-RUN: No se harán cambios reales');
\WP_CLI::line('');
}
\WP_CLI::line('🚀 Iniciando migración de base de datos...');
\WP_CLI::line('');
$migrator = new DatabaseMigrator($wpdb);
if ($dry_run) {
// Simular migración
$this->simulateMigration($migrator);
return;
}
// Ejecutar migración real
$start_time = microtime(true);
$result = $migrator->migrate();
$end_time = microtime(true);
$duration = round($end_time - $start_time, 2);
\WP_CLI::line('');
if ($result['success']) {
\WP_CLI::success('✅ ' . $result['message']);
\WP_CLI::line('');
\WP_CLI::line('📊 Estadísticas de Migración:');
\WP_CLI::line(' ├─ Components migrados: ' . ($result['stats']['components']['migrated'] ?? 0));
\WP_CLI::line(' ├─ Defaults migrados: ' . ($result['stats']['defaults']['migrated'] ?? 0));
\WP_CLI::line(' ├─ Tiempo de ejecución: ' . $duration . 's');
\WP_CLI::line(' └─ Validación: ' . $result['stats']['validation']['message']);
\WP_CLI::line('');
\WP_CLI::line('⚠️ IMPORTANTE: Tablas legacy respaldadas con sufijo _backup');
\WP_CLI::line(' Validar funcionamiento por 7-30 días antes de limpiar backup.');
\WP_CLI::line(' Comando para limpiar: wp roi-theme cleanup-backup');
\WP_CLI::line('');
} else {
\WP_CLI::error('❌ ' . $result['message']);
}
}
/**
* Limpiar tablas de backup después de validar migración
*
* IMPORTANTE: Solo ejecutar después de validar que todo funciona correctamente
*
* ## EJEMPLOS
*
* wp roi-theme cleanup-backup
*
* @param array $args Argumentos posicionales
* @param array $assoc_args Argumentos asociativos
* @return void
*/
public function cleanup_backup(array $args, array $assoc_args): void
{
global $wpdb;
\WP_CLI::line('');
\WP_CLI::confirm(
'⚠️ ¿Estás seguro de eliminar las tablas de backup? Esta acción es IRREVERSIBLE.',
$assoc_args
);
$migrator = new DatabaseMigrator($wpdb);
$migrator->cleanupBackup();
\WP_CLI::line('');
\WP_CLI::success('✅ Tablas de backup eliminadas correctamente');
\WP_CLI::line('');
}
/**
* Simular migración sin hacer cambios reales
*
* @param DatabaseMigrator $migrator Instancia del migrador
* @return void
*/
private function simulateMigration(DatabaseMigrator $migrator): void
{
global $wpdb;
\WP_CLI::line('Verificando tablas legacy...');
// Verificar existencia de tablas
$legacy_components = $wpdb->prefix . 'roi_theme_components';
$legacy_defaults = $wpdb->prefix . 'roi_theme_components_defaults';
$components_exist = $wpdb->get_var("SHOW TABLES LIKE '{$legacy_components}'") === $legacy_components;
$defaults_exist = $wpdb->get_var("SHOW TABLES LIKE '{$legacy_defaults}'") === $legacy_defaults;
if (!$components_exist || !$defaults_exist) {
\WP_CLI::error('Tablas legacy no encontradas. No hay nada que migrar.');
return;
}
\WP_CLI::line('✓ Tablas legacy encontradas');
// Contar registros
$components_count = $wpdb->get_var("SELECT COUNT(*) FROM {$legacy_components}");
$defaults_count = $wpdb->get_var("SELECT COUNT(*) FROM {$legacy_defaults}");
\WP_CLI::line('');
\WP_CLI::line('📊 Datos a migrar:');
\WP_CLI::line(" ├─ Components: {$components_count} registros");
\WP_CLI::line(" └─ Defaults: {$defaults_count} registros");
\WP_CLI::line('');
\WP_CLI::line('Operaciones que se ejecutarían:');
\WP_CLI::line(' 1. Crear tablas v2 con nueva estructura (config_group)');
\WP_CLI::line(' 2. Migrar ' . ($components_count + $defaults_count) . ' registros');
\WP_CLI::line(' 3. Validar integridad de datos');
\WP_CLI::line(' 4. Renombrar tablas (legacy → _backup, v2 → producción)');
\WP_CLI::line('');
\WP_CLI::success('✅ Simulación completada. Ejecutar sin --dry-run para migración real.');
\WP_CLI::line('');
}
}
// Registrar comando WP-CLI
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('roi-theme', MigrationCommand::class);
}

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Adapters;
use ROITheme\Component\Infrastructure\DI\DIContainer;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
/**
* LegacyDBManagerAdapter - Adapter para ROI_DB_Manager deprecated
*
* RESPONSABILIDAD: Traducir llamadas legacy a la nueva arquitectura
*
* PATRÓN: Adapter Pattern
* - Interfaz antigua (ROI_DB_Manager methods)
* - Implementación nueva (Clean Architecture repositories)
*
* Este adapter mantiene compatibilidad backward mientras se migra el código
*
* @package ROITheme\Infrastructure\Adapters
*/
final class LegacyDBManagerAdapter
{
private ComponentRepositoryInterface $componentRepository;
private ComponentDefaultsRepositoryInterface $defaultsRepository;
public function __construct()
{
global $wpdb;
// Get repositories from DI Container
$schemasPath = get_template_directory() . '/schemas';
$container = new DIContainer($wpdb, $schemasPath);
$this->componentRepository = $container->getComponentRepository();
$this->defaultsRepository = $container->getDefaultsRepository();
}
/**
* Save component configuration (legacy method)
*
* Adapta save_config() legacy a WordPressComponentRepository::save()
*
* @param string $component_name Component name
* @param string $config_key Configuration key
* @param mixed $config_value Configuration value
* @param string $data_type Data type
* @param string|null $version Schema version
* @param string $table_type Table type (components or defaults)
* @return bool Success status
*/
public function save_config(
string $component_name,
string $config_key,
$config_value,
string $data_type = 'string',
?string $version = null,
string $table_type = 'components'
): bool {
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
// Save to defaults repository
$config = $this->defaultsRepository->exists($componentNameVO)
? $this->defaultsRepository->getByName($componentNameVO)
: ComponentConfiguration::fromArray([]);
$data = $config->toArray();
$data[$config_key] = $this->convertValue($config_value, $data_type);
$newConfig = ComponentConfiguration::fromArray($data);
$this->defaultsRepository->save($componentNameVO, $newConfig);
return true;
}
// Save to component repository
$component = $this->componentRepository->findByName($componentNameVO);
if ($component === null) {
// Create new component with this configuration
$data = [
'visibility' => ['is_enabled' => true],
'content' => [$config_key => $this->convertValue($config_value, $data_type)],
'styles' => [],
'config' => []
];
$newComponent = new \ROITheme\Domain\Component\Component(
$component_name,
$component_name,
$data
);
$this->componentRepository->save($newComponent);
} else {
// Update existing component
$data = $component->getData();
// Determinar en qué sección guardar
$section = $this->determineSection($config_key);
if (!isset($data[$section])) {
$data[$section] = [];
}
$data[$section][$config_key] = $this->convertValue($config_value, $data_type);
$updatedComponent = $component->withData($data);
$this->componentRepository->save($updatedComponent);
}
return true;
} catch (\Exception $e) {
// Log error but maintain backward compatibility
error_log("LegacyDBManagerAdapter::save_config error: " . $e->getMessage());
return false;
}
}
/**
* Get component configuration (legacy method)
*
* Adapta get_config() legacy a WordPressComponentRepository::findByName()
*
* @param string $component_name Component name
* @param string|null $config_key Specific configuration key (null for all)
* @param string $table_type Table type (components or defaults)
* @return mixed Configuration value(s) or null
*/
public function get_config(
string $component_name,
?string $config_key = null,
string $table_type = 'components'
) {
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
if (!$this->defaultsRepository->exists($componentNameVO)) {
return null;
}
$config = $this->defaultsRepository->getByName($componentNameVO);
$data = $config->toArray();
return $config_key ? ($data[$config_key] ?? null) : $data;
}
// Get from component repository
$component = $this->componentRepository->findByName($componentNameVO);
if ($component === null) {
return null;
}
$data = $component->getData();
if ($config_key === null) {
// Return all configuration
return $this->flattenComponentData($data);
}
// Search for key in all sections
foreach (['visibility', 'content', 'styles', 'config'] as $section) {
if (isset($data[$section][$config_key])) {
return $data[$section][$config_key];
}
}
return null;
} catch (\Exception $e) {
error_log("LegacyDBManagerAdapter::get_config error: " . $e->getMessage());
return null;
}
}
/**
* Delete component configuration (legacy method)
*
* Adapta delete_config() legacy a WordPressComponentRepository::delete()
*
* @param string $component_name Component name
* @param string $table_type Table type (components or defaults)
* @return bool Success status
*/
public function delete_config(string $component_name, string $table_type = 'components'): bool
{
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
return $this->defaultsRepository->delete($componentNameVO);
}
return $this->componentRepository->delete($componentNameVO);
} catch (\Exception $e) {
error_log("LegacyDBManagerAdapter::delete_config error: " . $e->getMessage());
return false;
}
}
/**
* Get component by table (legacy method)
*
* @param string $component_name Component name
* @param string $table_type Table type
* @return array|null Component data or null
*/
public function get_component_by_table(string $component_name, string $table_type = 'components'): ?array
{
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
if (!$this->defaultsRepository->exists($componentNameVO)) {
return null;
}
$config = $this->defaultsRepository->getByName($componentNameVO);
return [
'component_name' => $component_name,
'default_schema' => json_encode($config->toArray()),
'created_at' => '',
'updated_at' => ''
];
}
$component = $this->componentRepository->findByName($componentNameVO);
if ($component === null) {
return null;
}
return [
'component_name' => $component_name,
'configuration' => json_encode($component->getData()),
'is_enabled' => $component->isEnabled() ? 1 : 0,
'created_at' => '',
'updated_at' => ''
];
} catch (\Exception $e) {
error_log("LegacyDBManagerAdapter::get_component_by_table error: " . $e->getMessage());
return null;
}
}
/**
* Convert value to appropriate type
*
* @param mixed $value Value to convert
* @param string $type Target type
* @return mixed Converted value
*/
private function convertValue($value, string $type)
{
switch ($type) {
case 'boolean':
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
case 'integer':
return (int) $value;
case 'array':
return is_array($value) ? $value : json_decode($value, true);
case 'string':
default:
return (string) $value;
}
}
/**
* Determine which section a config key belongs to
*
* @param string $key Configuration key
* @return string Section name
*/
private function determineSection(string $key): string
{
// Visibility keys
if (in_array($key, ['is_enabled', 'sticky', 'show_on_mobile', 'show_on_desktop'])) {
return 'visibility';
}
// Style keys
if (strpos($key, 'color') !== false || strpos($key, 'font') !== false || strpos($key, 'size') !== false) {
return 'styles';
}
// Config keys
if (in_array($key, ['auto_close', 'close_delay', 'animation'])) {
return 'config';
}
// Default to content
return 'content';
}
/**
* Flatten component data for legacy compatibility
*
* @param array $data Component data
* @return array Flattened data
*/
private function flattenComponentData(array $data): array
{
$flattened = [];
foreach ($data as $section => $values) {
if (is_array($values)) {
foreach ($values as $key => $value) {
$flattened[$key] = $value;
}
}
}
return $flattened;
}
}

View File

@@ -0,0 +1,350 @@
# Legacy Adapters - Patrón Adapter para Compatibilidad
Este directorio contiene **Adapters** que mantienen compatibilidad backward con código legacy mientras redirigen internamente a la nueva arquitectura Clean Architecture.
## Tabla de Contenidos
1. [¿Qué es un Adapter?](#qué-es-un-adapter)
2. [¿Por qué usar Adapters?](#por-qué-usar-adapters)
3. [Adapters Disponibles](#adapters-disponibles)
4. [Cómo Migrar del Código Legacy](#cómo-migrar-del-código-legacy)
5. [Ejemplos de Uso](#ejemplos-de-uso)
6. [Deprecation Strategy](#deprecation-strategy)
7. [Timeline de Eliminación](#timeline-de-eliminación)
---
## ¿Qué es un Adapter?
Un **Adapter** es un patrón de diseño que permite que interfaces incompatibles trabajen juntas. En este contexto:
```
┌─────────────────┐
│ Legacy Code │ ← API antigua (ROI_DB_Manager)
│ (deprecated) │
└────────┬────────┘
│ usa
┌─────────────────┐
│ ADAPTER │ ← Traduce llamadas legacy → nueva arquitectura
│ LegacyDBManager │
│ Adapter │
└────────┬────────┘
│ delega a
┌─────────────────┐
│ Clean Arch │ ← Arquitectura limpia (Repositories, UseCases)
│ Repositories │
│ UseCases │
└─────────────────┘
```
## ¿Por qué usar Adapters?
### Ventajas
**Compatibilidad Backward**: El código existente sigue funcionando sin cambios
**Migración Gradual**: Puedes migrar componente por componente
**Zero Downtime**: No hay riesgo de romper la aplicación
**Logging de Uso**: Trackea qué código legacy aún se está usando
**Testing Facilitado**: Puedes testear nueva y vieja arquitectura en paralelo
### Estrategia de Migración
1. **Fase 1** (actual): Código legacy deprecated pero funcional vía adapters
2. **Fase 2** (v2.1-2.9): Migración gradual del código a nueva arquitectura
3. **Fase 3** (v3.0): Eliminación completa del código legacy
---
## Adapters Disponibles
### LegacyDBManagerAdapter
**Ubicación**: `src/Infrastructure/Adapters/LegacyDBManagerAdapter.php`
**Propósito**: Adapta la API antigua de `ROI_DB_Manager` a los nuevos Repositories de Clean Architecture.
**Métodos adaptados**:
| Método Legacy | Nuevo Equivalente |
|--------------|-------------------|
| `save_config($name, $key, $value, $type, $version, $table)` | `ComponentRepository::save(Component)` |
| `get_config($name, $key, $table)` | `ComponentRepository::findByName(ComponentName)` |
| `delete_config($name, $key, $table)` | `ComponentRepository::delete(ComponentName)` |
**Cómo funciona**:
```php
// OLD WAY (deprecated pero funciona vía adapter)
$db_manager = new ROI_DB_Manager();
$db_manager->save_config('navbar', 'background_color', '#000000', 'string');
// ↓ El adapter internamente hace esto ↓
// NEW WAY (Clean Architecture)
$container = new DIContainer($wpdb, $schemasPath);
$repository = $container->getComponentRepository();
$componentName = new ComponentName('navbar');
$component = $repository->findByName($componentName);
$data = $component->getData();
$data['styles']['background_color'] = '#000000';
$updatedComponent = $component->withData($data);
$repository->save($updatedComponent);
```
---
## Cómo Migrar del Código Legacy
### Paso 1: Identificar Uso Legacy
Usa el script de detección:
```bash
php scripts/detect-legacy-usage.php
```
Esto generará un reporte de dónde se está usando código deprecated.
### Paso 2: Entender el Mapeo
#### Guardar Configuración
**Antes (Legacy)**:
```php
$db_manager = new ROI_DB_Manager();
$result = $db_manager->save_config(
'top_notification_bar', // component_name
'text', // config_key
'Welcome!', // config_value
'string', // data_type
'1.0', // version
'components' // table_type
);
```
**Después (Clean Architecture)**:
```php
use ROITheme\Infrastructure\DI\DIContainer;
use ROITheme\Domain\Component\ValueObjects\ComponentName;
global $wpdb;
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
$repository = $container->getComponentRepository();
$componentName = new ComponentName('top_notification_bar');
$component = $repository->findByName($componentName);
if ($component) {
$data = $component->getData();
$data['content']['text'] = 'Welcome!';
$updatedComponent = $component->withData($data);
$repository->save($updatedComponent);
}
```
#### Obtener Configuración
**Antes (Legacy)**:
```php
$db_manager = new ROI_DB_Manager();
$text = $db_manager->get_config('top_notification_bar', 'text');
```
**Después (Clean Architecture)**:
```php
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
$repository = $container->getComponentRepository();
$componentName = new ComponentName('top_notification_bar');
$component = $repository->findByName($componentName);
if ($component) {
$data = $component->getData();
$text = $data['content']['text'] ?? '';
}
```
#### Eliminar Configuración
**Antes (Legacy)**:
```php
$db_manager = new ROI_DB_Manager();
$db_manager->delete_config('navbar', null, 'components');
```
**Después (Clean Architecture)**:
```php
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
$repository = $container->getComponentRepository();
$componentName = new ComponentName('navbar');
$repository->delete($componentName);
```
---
## Ejemplos de Uso
### Ejemplo 1: Migrar save_config a Component::save()
```php
// ❌ OLD - Deprecated
$db_manager = new ROI_DB_Manager();
$db_manager->save_config('navbar', 'logo_url', 'https://example.com/logo.png', 'string');
// ✅ NEW - Recomendado
use ROITheme\Infrastructure\DI\DIContainer;
use ROITheme\Domain\Component\ValueObjects\ComponentName;
global $wpdb;
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
$repository = $container->getComponentRepository();
$name = new ComponentName('navbar');
$component = $repository->findByName($name);
if ($component) {
$data = $component->getData();
$data['content']['logo_url'] = 'https://example.com/logo.png';
$repository->save($component->withData($data));
}
```
### Ejemplo 2: Migrar get_config a Component::getData()
```php
// ❌ OLD - Deprecated
$db_manager = new ROI_DB_Manager();
$isSticky = $db_manager->get_config('navbar', 'is_sticky');
// ✅ NEW - Recomendado
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
$repository = $container->getComponentRepository();
$name = new ComponentName('navbar');
$component = $repository->findByName($name);
$isSticky = false;
if ($component) {
$data = $component->getData();
$isSticky = $data['config']['is_sticky'] ?? false;
}
```
### Ejemplo 3: Usar Dependency Injection
Para código más limpio, inyecta el repository en lugar de crearlo cada vez:
```php
class MyComponentController
{
private ComponentRepositoryInterface $repository;
public function __construct(ComponentRepositoryInterface $repository)
{
$this->repository = $repository;
}
public function updateNavbarLogo(string $logoUrl): void
{
$name = new ComponentName('navbar');
$component = $this->repository->findByName($name);
if ($component) {
$data = $component->getData();
$data['content']['logo_url'] = $logoUrl;
$this->repository->save($component->withData($data));
}
}
}
// Uso
global $wpdb;
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
$controller = new MyComponentController($container->getComponentRepository());
$controller->updateNavbarLogo('https://example.com/new-logo.png');
```
---
## Deprecation Strategy
### Logging Automático
Cuando usas código legacy, automáticamente se registra en:
1. **Base de datos**: Tabla `wp_roi_theme_deprecation_log`
2. **Archivo log**: `wp-content/uploads/roi-theme-logs/deprecation-YYYY-MM-DD.log`
### Ver Estadísticas de Uso
```php
use ROITheme\Infrastructure\Logging\DeprecationLogger;
$logger = DeprecationLogger::getInstance();
// Top 10 métodos deprecated más usados
$topDeprecated = $logger->getTopDeprecated(10);
// Uso en los últimos 7 días
$stats = $logger->getStatistics(7);
// Limpiar logs antiguos (más de 30 días)
$deleted = $logger->clearOldLogs(30);
```
### Warnings en WordPress
Cuando WP_DEBUG está activo, verás warnings como:
```
Deprecated: ROI_DB_Manager::save_config is deprecated since version 2.0.0!
Use ROITheme\Application\UseCases\SaveComponent\SaveComponentUseCase::execute() instead.
```
---
## Timeline de Eliminación
| Versión | Acción | Fecha Estimada |
|---------|--------|----------------|
| **2.0.0** | ✅ Código legacy marcado como @deprecated | Actual |
| **2.1.0** | ⚠️ Warnings visibles con WP_DEBUG | +1 mes |
| **2.5.0** | ⚠️ Warnings visibles siempre (no solo DEBUG) | +3 meses |
| **2.9.0** | ⚠️ Última versión con código legacy funcional | +6 meses |
| **3.0.0** | ❌ Código legacy eliminado completamente | +12 meses |
### Recomendaciones
1. **Migra YA** si estás escribiendo código nuevo
2. **Migra en 3 meses** si tu código actual usa métodos deprecated
3. **Migra en 6 meses** como máximo - después se romperá
---
## Recursos Adicionales
- [Guía de Migración Completa](../../docs/MIGRATION_GUIDE.md)
- [Deprecation Timeline](../../docs/DEPRECATION_TIMELINE.md)
- [Clean Architecture Documentation](../../docs/CLEAN_ARCHITECTURE.md)
- [Testing Guide](../../docs/TESTING.md)
## Soporte
Si tienes preguntas sobre la migración:
1. Revisa la [Guía de Migración](../../docs/MIGRATION_GUIDE.md)
2. Ejecuta `php scripts/detect-legacy-usage.php` para ver tu uso actual
3. Revisa los ejemplos en `tests/Integration/Infrastructure/Adapters/`
---
**Última actualización**: Fase 8 - Deprecar Legacy (2.0.0)

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\DI;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
use ROITheme\Shared\Domain\ValidationServiceInterface;
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressComponentRepository;
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
use ROITheme\Component\Infrastructure\Services\WordPressValidationService;
use ROITheme\Component\Infrastructure\Services\WordPressCacheService;
use ROITheme\Component\Infrastructure\Services\SchemaSyncService;
use ROITheme\Component\Infrastructure\Services\CleanupService;
/**
* DIContainer - Contenedor de Inyección de Dependencias
*
* RESPONSABILIDAD: Crear y gestionar instancias de servicios
*
* PATRÓN: Service Locator + Lazy Initialization
*
* @package ROITheme\Infrastructure\DI
*/
final class DIContainer
{
private array $instances = [];
public function __construct(
private \wpdb $wpdb,
private string $schemasPath
) {}
/**
* Obtener repositorio de componentes
*/
public function getComponentRepository(): ComponentRepositoryInterface
{
if (!isset($this->instances['componentRepository'])) {
$this->instances['componentRepository'] = new WordPressComponentRepository(
$this->wpdb
);
}
return $this->instances['componentRepository'];
}
/**
* Obtener repositorio de defaults
*/
public function getDefaultsRepository(): ComponentDefaultsRepositoryInterface
{
if (!isset($this->instances['defaultsRepository'])) {
$this->instances['defaultsRepository'] = new WordPressDefaultsRepository(
$this->wpdb
);
}
return $this->instances['defaultsRepository'];
}
/**
* Obtener servicio de validación
*/
public function getValidationService(): ValidationServiceInterface
{
if (!isset($this->instances['validationService'])) {
$this->instances['validationService'] = new WordPressValidationService(
$this->getDefaultsRepository()
);
}
return $this->instances['validationService'];
}
/**
* Obtener servicio de cache
*/
public function getCacheService(): CacheServiceInterface
{
if (!isset($this->instances['cacheService'])) {
$this->instances['cacheService'] = new WordPressCacheService(
$this->wpdb
);
}
return $this->instances['cacheService'];
}
/**
* Obtener servicio de sincronización de schemas
*/
public function getSchemaSyncService(): SchemaSyncService
{
if (!isset($this->instances['schemaSyncService'])) {
$this->instances['schemaSyncService'] = new SchemaSyncService(
$this->getDefaultsRepository(),
$this->schemasPath
);
}
return $this->instances['schemaSyncService'];
}
/**
* Obtener servicio de limpieza
*/
public function getCleanupService(): CleanupService
{
if (!isset($this->instances['cleanupService'])) {
$this->instances['cleanupService'] = new CleanupService(
$this->getComponentRepository(),
$this->getDefaultsRepository()
);
}
return $this->instances['cleanupService'];
}
/**
* Resetear todas las instancias del contenedor
*
* Útil para testing para asegurar que cada test comienza con un estado limpio
*/
public function reset(): void
{
$this->instances = [];
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Facades;
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentUseCase;
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentRequest;
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentUseCase;
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentRequest;
use ROITheme\Component\Infrastructure\DI\DIContainer;
/**
* ComponentManager - Facade para el sistema
*
* RESPONSABILIDAD: Punto de entrada unificado y simple
*
* PATRÓN: Facade
* - Oculta complejidad interna
* - Proporciona API simple
* - Orquesta Use Cases
*
* USO:
* ```php
* $manager = new ComponentManager($container);
* $result = $manager->saveComponent('top_bar', $data);
* ```
*
* @package ROITheme\Infrastructure\Facades
*/
final class ComponentManager
{
public function __construct(
private DIContainer $container
) {}
/**
* Guardar componente
*
* @param string $componentName
* @param array $data
* @return array ['success' => bool, 'data' => mixed, 'errors' => array|null]
*/
public function saveComponent(string $componentName, array $data): array
{
$request = new SaveComponentRequest($componentName, $data);
$useCase = new SaveComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getValidationService(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
return $response->toArray();
}
/**
* Obtener componente
*
* @param string $componentName
* @return array ['success' => bool, 'data' => mixed, 'error' => string|null]
*/
public function getComponent(string $componentName): array
{
$request = new GetComponentRequest($componentName);
$useCase = new GetComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
return $response->toArray();
}
/**
* Sincronizar schemas desde JSON
*
* @param string|null $componentName Si null, sincroniza todos
* @return array
*/
public function syncSchema(?string $componentName = null): array
{
$syncService = $this->container->getSchemaSyncService();
if ($componentName === null) {
return $syncService->syncAll();
}
return $syncService->syncComponent($componentName);
}
/**
* Limpiar componentes obsoletos
*
* @return array ['removed' => array]
*/
public function cleanup(): array
{
$cleanupService = $this->container->getCleanupService();
return $cleanupService->removeObsolete();
}
/**
* Invalidar todo el cache
*
* @return bool
*/
public function invalidateCache(): bool
{
return $this->container->getCacheService()->invalidateAll();
}
}

View File

@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Logging;
/**
* DeprecationLogger - Sistema de logging para código deprecated
*
* RESPONSABILIDAD: Registrar uso de código legacy para análisis y migración
*
* FEATURES:
* - Log en archivo de texto
* - Almacenamiento en base de datos para reportes
* - Niveles de severidad
* - Deduplicación de entradas
*
* @package ROITheme\Infrastructure\Logging
*/
final class DeprecationLogger
{
private static ?self $instance = null;
private string $logFile;
private \wpdb $wpdb;
private string $tableName;
private bool $enabled = true;
private function __construct()
{
global $wpdb;
$this->wpdb = $wpdb;
$this->tableName = $wpdb->prefix . 'roi_theme_deprecation_log';
$uploadDir = wp_upload_dir();
$logDir = $uploadDir['basedir'] . '/roi-theme-logs';
if (!file_exists($logDir)) {
wp_mkdir_p($logDir);
}
$this->logFile = $logDir . '/deprecation-' . date('Y-m-d') . '.log';
// Asegurar que la tabla existe
$this->ensureTableExists();
}
/**
* Get singleton instance
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Log deprecation use
*
* @param string $class Class name
* @param string $method Method name
* @param array $args Arguments passed
* @param string $replacement Suggested replacement
* @param string $version Version deprecated in
* @return void
*/
public function log(
string $class,
string $method,
array $args = [],
string $replacement = '',
string $version = '2.0.0'
): void {
if (!$this->enabled) {
return;
}
$logEntry = [
'timestamp' => current_time('mysql'),
'class' => $class,
'method' => $method,
'args' => json_encode($args),
'replacement' => $replacement,
'version' => $version,
'backtrace' => $this->getSimplifiedBacktrace(),
'url' => $this->getCurrentUrl(),
'user_id' => get_current_user_id()
];
// Log to file
$this->logToFile($logEntry);
// Log to database (with deduplication)
$this->logToDatabase($logEntry);
// Trigger action for external monitoring
do_action('roi_theme_deprecation_logged', $logEntry);
}
/**
* Get deprecation statistics
*
* @param int $days Number of days to analyze
* @return array Statistics
*/
public function getStatistics(int $days = 7): array
{
$since = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$sql = $this->wpdb->prepare(
"SELECT
class,
method,
COUNT(*) as usage_count,
MAX(timestamp) as last_used,
replacement
FROM {$this->tableName}
WHERE timestamp >= %s
GROUP BY class, method, replacement
ORDER BY usage_count DESC",
$since
);
return $this->wpdb->get_results($sql, ARRAY_A) ?: [];
}
/**
* Get most used deprecated methods
*
* @param int $limit Number of results
* @return array Top deprecated methods
*/
public function getTopDeprecated(int $limit = 10): array
{
$sql = $this->wpdb->prepare(
"SELECT
class,
method,
COUNT(*) as usage_count,
replacement
FROM {$this->tableName}
GROUP BY class, method, replacement
ORDER BY usage_count DESC
LIMIT %d",
$limit
);
return $this->wpdb->get_results($sql, ARRAY_A) ?: [];
}
/**
* Clear old logs
*
* @param int $days Keep logs newer than this many days
* @return int Number of deleted entries
*/
public function clearOldLogs(int $days = 30): int
{
$since = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$deleted = $this->wpdb->query(
$this->wpdb->prepare(
"DELETE FROM {$this->tableName} WHERE timestamp < %s",
$since
)
);
return (int) $deleted;
}
/**
* Enable/disable logging
*
* @param bool $enabled
*/
public function setEnabled(bool $enabled): void
{
$this->enabled = $enabled;
}
/**
* Log to file
*
* @param array $entry Log entry
*/
private function logToFile(array $entry): void
{
$message = sprintf(
"[%s] %s::%s() deprecated in v%s - Use %s instead\n",
$entry['timestamp'],
$entry['class'],
$entry['method'],
$entry['version'],
$entry['replacement'] ?: 'see documentation'
);
if (!empty($entry['backtrace'])) {
$message .= " Called from: {$entry['backtrace']}\n";
}
if (!empty($entry['url'])) {
$message .= " URL: {$entry['url']}\n";
}
$message .= " ---\n";
error_log($message, 3, $this->logFile);
}
/**
* Log to database with deduplication
*
* @param array $entry Log entry
*/
private function logToDatabase(array $entry): void
{
// Check if same call was logged in last hour (deduplication)
$hash = md5($entry['class'] . $entry['method'] . $entry['url']);
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$exists = $this->wpdb->get_var(
$this->wpdb->prepare(
"SELECT id FROM {$this->tableName}
WHERE call_hash = %s
AND timestamp > %s
LIMIT 1",
$hash,
$oneHourAgo
)
);
if ($exists) {
// Update count instead of creating new entry
$this->wpdb->query(
$this->wpdb->prepare(
"UPDATE {$this->tableName}
SET usage_count = usage_count + 1,
timestamp = %s
WHERE id = %d",
$entry['timestamp'],
$exists
)
);
return;
}
// Insert new entry
$this->wpdb->insert(
$this->tableName,
[
'timestamp' => $entry['timestamp'],
'class' => $entry['class'],
'method' => $entry['method'],
'args' => $entry['args'],
'replacement' => $entry['replacement'],
'version' => $entry['version'],
'backtrace' => $entry['backtrace'],
'url' => $entry['url'],
'user_id' => $entry['user_id'],
'call_hash' => $hash,
'usage_count' => 1
],
['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d']
);
}
/**
* Get simplified backtrace
*
* @return string Simplified backtrace
*/
private function getSimplifiedBacktrace(): string
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
// Skip first 3 frames (this method, log method, deprecated function)
$relevant = array_slice($trace, 3, 2);
$parts = [];
foreach ($relevant as $frame) {
if (isset($frame['file']) && isset($frame['line'])) {
$file = basename($frame['file']);
$parts[] = "{$file}:{$frame['line']}";
}
}
return implode(' -> ', $parts);
}
/**
* Get current URL
*
* @return string Current URL
*/
private function getCurrentUrl(): string
{
if (!isset($_SERVER['REQUEST_URI'])) {
return '';
}
$scheme = is_ssl() ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return $scheme . '://' . $host . $uri;
}
/**
* Ensure database table exists
*/
private function ensureTableExists(): void
{
$charset_collate = $this->wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
timestamp datetime NOT NULL,
class varchar(255) NOT NULL,
method varchar(255) NOT NULL,
args text,
replacement varchar(255) DEFAULT '',
version varchar(20) DEFAULT '2.0.0',
backtrace text,
url varchar(500) DEFAULT '',
user_id bigint(20) unsigned DEFAULT 0,
call_hash varchar(32) NOT NULL,
usage_count int(11) DEFAULT 1,
PRIMARY KEY (id),
KEY class_method (class, method),
KEY timestamp (timestamp),
KEY call_hash (call_hash)
) $charset_collate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
}
/**
* Prevent cloning
*/
private function __clone() {}
/**
* Prevent unserialization
*/
public function __wakeup()
{
throw new \Exception('Cannot unserialize singleton');
}
}

View File

@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Persistence\WordPress;
/**
* DatabaseMigrator - Migrador de Base de Datos Legacy a Clean Architecture
*
* Responsabilidad: Transformar estructura de BD legacy a nueva estructura
*
* ESTRATEGIA DE MIGRACIÓN:
* 1. Crear tablas nuevas con sufijo _v2
* 2. Migrar datos de legacy a nuevas tablas
* 3. Validar integridad de datos migrados
* 4. Renombrar tablas (legacy → _backup, v2 → producción)
* 5. Mantener backup por 30 días para rollback
*
* TRANSFORMACIONES:
* - Agregar columna config_group (inferida desde config_key)
* - Renombrar version → schema_version
* - Optimizar índices para consultas jerárquicas
*
* SEGURIDAD:
* - Rollback automático si falla validación
* - Backup de tablas legacy preservado
* - Logging detallado de cada operación
*
* USO:
* ```php
* global $wpdb;
* $migrator = new DatabaseMigrator($wpdb);
* $result = $migrator->migrate();
*
* if ($result['success']) {
* echo "Migración exitosa: " . $result['stats']['components']['migrated'] . " registros";
* } else {
* echo "Error: " . $result['message'];
* }
* ```
*/
final class DatabaseMigrator
{
/**
* @var \wpdb Instancia de WordPress Database
*/
private \wpdb $wpdb;
/**
* @var string Charset y Collation de la BD
*/
private string $charset_collate;
/**
* @var array Log de operaciones ejecutadas
*/
private array $log = [];
/**
* Constructor
*
* @param \wpdb $wpdb Instancia de WordPress Database
*/
public function __construct(\wpdb $wpdb)
{
$this->wpdb = $wpdb;
$this->charset_collate = $wpdb->get_charset_collate();
}
/**
* Ejecutar migración completa de BD
*
* @return array{success: bool, message: string, stats: array, log: array}
*/
public function migrate(): array
{
$this->log('🚀 Iniciando migración de base de datos');
try {
// 1. Verificar que tablas legacy existen
if (!$this->legacyTablesExist()) {
return [
'success' => false,
'message' => 'Tablas legacy no encontradas. No hay nada que migrar.',
'stats' => [],
'log' => $this->log
];
}
$this->log('✓ Tablas legacy encontradas');
// 2. Crear tablas v2 con nueva estructura
$this->createV2Tables();
$this->log('✓ Tablas v2 creadas con nueva estructura');
// 3. Migrar datos de components
$componentsStats = $this->migrateComponentsData();
$this->log("✓ Components migrados: {$componentsStats['migrated']} registros");
// 4. Migrar datos de defaults
$defaultsStats = $this->migrateDefaultsData();
$this->log("✓ Defaults migrados: {$defaultsStats['migrated']} registros");
// 5. Validar integridad de datos
$validation = $this->validateMigration();
if (!$validation['success']) {
throw new \RuntimeException(
'Validación de migración falló: ' . $validation['message']
);
}
$this->log('✓ Validación de integridad exitosa');
// 6. Hacer swap de tablas
$this->swapTables();
$this->log('✓ Swap de tablas completado (legacy → _backup, v2 → producción)');
// 7. Guardar metadata de migración
update_option('roi_theme_migration_date', current_time('mysql'));
update_option('roi_theme_migration_stats', [
'components' => $componentsStats,
'defaults' => $defaultsStats
]);
$this->log('✅ Migración completada exitosamente');
return [
'success' => true,
'message' => 'Migración completada exitosamente',
'stats' => [
'components' => $componentsStats,
'defaults' => $defaultsStats,
'validation' => $validation
],
'log' => $this->log
];
} catch (\Exception $e) {
$this->log('❌ Error durante migración: ' . $e->getMessage());
// Rollback en caso de error
$this->rollback();
return [
'success' => false,
'message' => 'Error durante migración: ' . $e->getMessage(),
'stats' => [],
'log' => $this->log
];
}
}
/**
* Verificar si las tablas legacy existen
*
* @return bool
*/
private function legacyTablesExist(): bool
{
$legacy_components = $this->wpdb->prefix . 'roi_theme_components';
$legacy_defaults = $this->wpdb->prefix . 'roi_theme_components_defaults';
$components_exist = $this->wpdb->get_var(
"SHOW TABLES LIKE '{$legacy_components}'"
) === $legacy_components;
$defaults_exist = $this->wpdb->get_var(
"SHOW TABLES LIKE '{$legacy_defaults}'"
) === $legacy_defaults;
return $components_exist && $defaults_exist;
}
/**
* Crear tablas v2 con nueva estructura
*
* @return void
*/
private function createV2Tables(): void
{
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
$components_v2 = $this->wpdb->prefix . 'roi_theme_components_v2';
$defaults_v2 = $this->wpdb->prefix . 'roi_theme_components_defaults_v2';
// Estructura mejorada con config_group
$table_structure = "
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
component_name VARCHAR(50) NOT NULL,
config_group VARCHAR(50) NOT NULL,
config_key VARCHAR(100) NOT NULL,
config_value TEXT NOT NULL,
data_type VARCHAR(20) NOT NULL DEFAULT 'string',
schema_version VARCHAR(10) NOT NULL DEFAULT '1.0.0',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY component_config (component_name, config_group, config_key),
INDEX idx_component (component_name),
INDEX idx_group (component_name, config_group),
INDEX idx_schema_version (component_name, schema_version),
INDEX idx_config_key (config_key)
";
$sql_components = "CREATE TABLE {$components_v2} ({$table_structure}) {$this->charset_collate};";
$sql_defaults = "CREATE TABLE {$defaults_v2} ({$table_structure}) {$this->charset_collate};";
dbDelta($sql_components);
dbDelta($sql_defaults);
}
/**
* Migrar datos de components legacy a v2
*
* @return array{migrated: int, skipped: int, errors: array}
*/
private function migrateComponentsData(): array
{
$legacy_table = $this->wpdb->prefix . 'roi_theme_components';
$v2_table = $this->wpdb->prefix . 'roi_theme_components_v2';
return $this->migrateTableData($legacy_table, $v2_table);
}
/**
* Migrar datos de defaults legacy a v2
*
* @return array{migrated: int, skipped: int, errors: array}
*/
private function migrateDefaultsData(): array
{
$legacy_table = $this->wpdb->prefix . 'roi_theme_components_defaults';
$v2_table = $this->wpdb->prefix . 'roi_theme_components_defaults_v2';
return $this->migrateTableData($legacy_table, $v2_table);
}
/**
* Migrar datos de una tabla legacy a v2
*
* @param string $legacy_table Nombre de tabla legacy
* @param string $v2_table Nombre de tabla v2
* @return array{migrated: int, skipped: int, errors: array}
*/
private function migrateTableData(string $legacy_table, string $v2_table): array
{
// Obtener todos los registros legacy
$legacy_rows = $this->wpdb->get_results(
"SELECT * FROM {$legacy_table}",
ARRAY_A
);
if (empty($legacy_rows)) {
return ['migrated' => 0, 'skipped' => 0, 'errors' => []];
}
$migrated = 0;
$skipped = 0;
$errors = [];
foreach ($legacy_rows as $row) {
try {
// Inferir config_group desde config_key
$group = $this->inferGroupFromKey($row['config_key']);
// Preparar datos para inserción
$data = [
'component_name' => $row['component_name'],
'config_group' => $group,
'config_key' => $row['config_key'],
'config_value' => $row['config_value'],
'data_type' => $row['data_type'],
'schema_version' => $row['version'] ?? '1.0.0',
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at']
];
// Insertar en tabla v2
$result = $this->wpdb->insert(
$v2_table,
$data,
['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
);
if ($result !== false) {
$migrated++;
} else {
$skipped++;
$errors[] = "Error migrando: {$row['component_name']}.{$row['config_key']}";
}
} catch (\Exception $e) {
$skipped++;
$errors[] = "Excepción migrando {$row['component_name']}.{$row['config_key']}: " . $e->getMessage();
}
}
return [
'migrated' => $migrated,
'skipped' => $skipped,
'errors' => $errors
];
}
/**
* Inferir grupo de configuración desde la clave
*
* HEURÍSTICA:
* - enabled, visible_* → visibility
* - message_*, cta_*, title_* → content
* - *_color, *_height, *_width, *_size → styles
* - Resto → general
*
* @param string $key Clave de configuración
* @return string Grupo inferido
*/
private function inferGroupFromKey(string $key): string
{
// Visibility
if (in_array($key, ['enabled', 'visible_desktop', 'visible_mobile', 'visible_tablet'], true)) {
return 'visibility';
}
// Content
if (str_starts_with($key, 'message_') ||
str_starts_with($key, 'cta_') ||
str_starts_with($key, 'title_')) {
return 'content';
}
// Styles
if (str_ends_with($key, '_color') ||
str_ends_with($key, '_height') ||
str_ends_with($key, '_width') ||
str_ends_with($key, '_size') ||
str_ends_with($key, '_font')) {
return 'styles';
}
// Fallback
return 'general';
}
/**
* Validar integridad de la migración
*
* @return array{success: bool, message: string, details: array}
*/
private function validateMigration(): array
{
$legacy_components = $this->wpdb->prefix . 'roi_theme_components';
$legacy_defaults = $this->wpdb->prefix . 'roi_theme_components_defaults';
$v2_components = $this->wpdb->prefix . 'roi_theme_components_v2';
$v2_defaults = $this->wpdb->prefix . 'roi_theme_components_defaults_v2';
$details = [];
// 1. Validar cantidad de registros components
$legacy_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$legacy_components}");
$v2_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$v2_components}");
$details['components_count'] = [
'legacy' => (int) $legacy_count,
'v2' => (int) $v2_count,
'match' => $legacy_count == $v2_count
];
if ($legacy_count != $v2_count) {
return [
'success' => false,
'message' => "Mismatch en cantidad de registros components: legacy={$legacy_count}, v2={$v2_count}",
'details' => $details
];
}
// 2. Validar cantidad de registros defaults
$legacy_defaults_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$legacy_defaults}");
$v2_defaults_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$v2_defaults}");
$details['defaults_count'] = [
'legacy' => (int) $legacy_defaults_count,
'v2' => (int) $v2_defaults_count,
'match' => $legacy_defaults_count == $v2_defaults_count
];
if ($legacy_defaults_count != $v2_defaults_count) {
return [
'success' => false,
'message' => "Mismatch en cantidad de registros defaults: legacy={$legacy_defaults_count}, v2={$v2_defaults_count}",
'details' => $details
];
}
// 3. Verificar componentes únicos
$legacy_component_names = $this->wpdb->get_col(
"SELECT DISTINCT component_name FROM {$legacy_components}"
);
$v2_component_names = $this->wpdb->get_col(
"SELECT DISTINCT component_name FROM {$v2_components}"
);
$missing_components = array_diff($legacy_component_names, $v2_component_names);
if (count($missing_components) > 0) {
return [
'success' => false,
'message' => "Faltan componentes en v2: " . implode(', ', $missing_components),
'details' => $details
];
}
// 4. Verificar grupos válidos
$invalid_groups = $this->wpdb->get_col(
"SELECT DISTINCT config_group FROM {$v2_components}
WHERE config_group NOT IN ('visibility', 'content', 'styles', 'general')"
);
if (count($invalid_groups) > 0) {
return [
'success' => false,
'message' => "Grupos inválidos encontrados: " . implode(', ', $invalid_groups),
'details' => $details
];
}
return [
'success' => true,
'message' => "Validación exitosa: {$legacy_count} components + {$legacy_defaults_count} defaults migrados correctamente",
'details' => $details
];
}
/**
* Hacer swap de tablas (legacy → backup, v2 → producción)
*
* @return void
*/
private function swapTables(): void
{
// Swap components
$this->wpdb->query(
"RENAME TABLE
{$this->wpdb->prefix}roi_theme_components TO {$this->wpdb->prefix}roi_theme_components_backup,
{$this->wpdb->prefix}roi_theme_components_v2 TO {$this->wpdb->prefix}roi_theme_components"
);
// Swap defaults
$this->wpdb->query(
"RENAME TABLE
{$this->wpdb->prefix}roi_theme_components_defaults TO {$this->wpdb->prefix}roi_theme_components_defaults_backup,
{$this->wpdb->prefix}roi_theme_components_defaults_v2 TO {$this->wpdb->prefix}roi_theme_components_defaults"
);
// Guardar timestamp de backup
update_option('roi_theme_migration_backup_date', current_time('mysql'));
}
/**
* Rollback: Eliminar tablas v2 en caso de error
*
* @return void
*/
private function rollback(): void
{
$this->log('⚠️ Ejecutando rollback...');
// Eliminar tablas v2 si existen
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_v2");
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_defaults_v2");
$this->log('✓ Rollback completado: tablas v2 eliminadas');
}
/**
* Limpiar tablas de backup (ejecutar después de validar migración)
*
* @return void
*/
public function cleanupBackup(): void
{
$this->log('🗑️ Eliminando tablas de backup...');
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_backup");
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_defaults_backup");
delete_option('roi_theme_migration_backup_date');
$this->log('✓ Tablas de backup eliminadas');
}
/**
* Agregar entrada al log
*
* @param string $message Mensaje a registrar
* @return void
*/
private function log(string $message): void
{
$timestamp = current_time('Y-m-d H:i:s');
$entry = "[{$timestamp}] {$message}";
$this->log[] = $entry;
error_log('ROI Theme Migration: ' . $message);
}
/**
* Obtener log completo de operaciones
*
* @return array
*/
public function getLog(): array
{
return $this->log;
}
}

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Persistence\WordPress;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Component\Domain\ValueObjects\ComponentVisibility;
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
use ROITheme\Component\Domain\Exceptions\ComponentNotFoundException;
/**
* WordPressComponentRepository - Implementación con WordPress/MySQL
*
* RESPONSABILIDAD: Persistir y recuperar componentes desde wp_roi_theme_components
*
* DEPENDENCIAS EXTERNAS:
* - wpdb (WordPress database abstraction)
* - Tabla wp_roi_theme_components (creada en Fase 2)
*
* CONVERSIÓN:
* - Entity → Array → MySQL (al guardar)
* - MySQL → Array → Entity (al recuperar)
*
* @package ROITheme\Infrastructure\Persistence\WordPress
*/
final class WordPressComponentRepository implements ComponentRepositoryInterface
{
private string $tableName;
/**
* @param \wpdb $wpdb WordPress database object
*/
public function __construct(
private \wpdb $wpdb
) {
$this->tableName = $this->wpdb->prefix . 'roi_theme_components';
}
/**
* Guardar componente
*
* INSERT si no existe, UPDATE si existe
*
* @param Component $component
* @return Component Componente guardado (con timestamps actualizados)
*/
public function save(Component $component): Component
{
$componentName = $component->name()->value();
// Verificar si ya existe
$existing = $this->findByName($componentName);
$data = [
'component_name' => $componentName,
'configuration' => json_encode($component->configuration()->toArray()),
'visibility' => json_encode($component->visibility()->toArray()),
'is_enabled' => $component->isEnabled() ? 1 : 0,
'schema_version' => $component->schemaVersion(),
'updated_at' => current_time('mysql')
];
if ($existing === null) {
// INSERT
$data['created_at'] = current_time('mysql');
$this->wpdb->insert(
$this->tableName,
$data,
['%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
} else {
// UPDATE
$this->wpdb->update(
$this->tableName,
$data,
['component_name' => $componentName],
['%s', '%s', '%s', '%d', '%s', '%s'],
['%s']
);
}
// Return the saved component by fetching it from the database
return $this->getByName($component->name());
}
/**
* Buscar componente por nombre
*
* @param ComponentName $name Nombre del componente
* @return Component|null Null si no existe
*/
public function findByName(ComponentName $name): ?Component
{
$sql = $this->wpdb->prepare(
"SELECT * FROM {$this->tableName} WHERE component_name = %s LIMIT 1",
$name->value()
);
$row = $this->wpdb->get_row($sql, ARRAY_A);
if ($row === null) {
return null;
}
return $this->rowToEntity($row);
}
/**
* Obtener componente por nombre (lanza excepción si no existe)
*
* @param ComponentName $name
* @return Component
* @throws ComponentNotFoundException
*/
public function getByName(ComponentName $name): Component
{
$component = $this->findByName($name);
if ($component === null) {
throw ComponentNotFoundException::withName($name->value());
}
return $component;
}
/**
* Obtener todos los componentes
*
* @return Component[]
*/
public function findAll(): array
{
$sql = "SELECT * FROM {$this->tableName} ORDER BY component_name ASC";
$rows = $this->wpdb->get_results($sql, ARRAY_A);
if (empty($rows)) {
return [];
}
return array_map(
fn($row) => $this->rowToEntity($row),
$rows
);
}
/**
* Eliminar componente
*
* @param ComponentName $name Nombre del componente
* @return bool True si eliminó exitosamente
*/
public function delete(ComponentName $name): bool
{
$result = $this->wpdb->delete(
$this->tableName,
['component_name' => $name->value()],
['%s']
);
return $result !== false;
}
/**
* Obtener componentes habilitados
*
* @return Component[]
*/
public function findEnabled(): array
{
$sql = "SELECT * FROM {$this->tableName} WHERE is_enabled = 1 ORDER BY component_name ASC";
$rows = $this->wpdb->get_results($sql, ARRAY_A);
if (empty($rows)) {
return [];
}
return array_map(
fn($row) => $this->rowToEntity($row),
$rows
);
}
/**
* Verificar si existe un componente con el nombre dado
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool
{
return $this->findByName($name) !== null;
}
/**
* Obtener cantidad total de componentes
*
* @return int
*/
public function count(): int
{
$sql = "SELECT COUNT(*) FROM {$this->tableName}";
return (int) $this->wpdb->get_var($sql);
}
/**
* Obtener componentes por grupo de configuración
*
* @param string $group Grupo de configuración (visibility, content, styles, general)
* @return Component[]
*/
public function findByConfigGroup(string $group): array
{
// For now, return all components as we don't have a specific column for groups
// This would require additional schema design
return $this->findAll();
}
/**
* Convertir fila de BD a Entity
*
* @param array $row Fila de la base de datos
* @return Component
*/
private function rowToEntity(array $row): Component
{
// Decodificar JSON
$configuration = json_decode($row['configuration'], true) ?? [];
$visibility = json_decode($row['visibility'], true) ?? [];
// Crear Value Objects
$name = new ComponentName($row['component_name']);
$config = ComponentConfiguration::fromArray($configuration);
$vis = ComponentVisibility::fromArray($visibility);
// Crear Entity
return new Component(
$name,
$config,
$vis,
(bool) $row['is_enabled'],
$row['schema_version']
);
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Persistence\WordPress;
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
/**
* WordPressDefaultsRepository - Defaults/Schemas desde MySQL
*
* RESPONSABILIDAD: Gestionar schemas de componentes (estructura, validaciones, defaults)
*
* TABLA: wp_roi_theme_defaults
*
* @package ROITheme\Infrastructure\Persistence\WordPress
*/
final class WordPressDefaultsRepository implements ComponentDefaultsRepositoryInterface
{
private string $tableName;
public function __construct(
private \wpdb $wpdb
) {
$this->tableName = $this->wpdb->prefix . 'roi_theme_defaults';
}
/**
* Obtener configuración por defecto de un componente
*
* @param ComponentName $name Nombre del componente
* @return ComponentConfiguration Configuración por defecto
*/
public function getByName(ComponentName $name): ComponentConfiguration
{
$sql = $this->wpdb->prepare(
"SELECT default_schema FROM {$this->tableName} WHERE component_name = %s LIMIT 1",
$name->value()
);
$result = $this->wpdb->get_var($sql);
if ($result === null) {
// Return empty configuration if no defaults found
return ComponentConfiguration::fromArray([]);
}
$schema = json_decode($result, true) ?? [];
return ComponentConfiguration::fromArray($schema);
}
/**
* Guardar configuración por defecto para un componente
*
* @param ComponentName $name Nombre del componente
* @param ComponentConfiguration $configuration Configuración por defecto
* @return void
*/
public function save(ComponentName $name, ComponentConfiguration $configuration): void
{
$existing = $this->exists($name);
$data = [
'component_name' => $name->value(),
'default_schema' => json_encode($configuration->all()),
'updated_at' => current_time('mysql')
];
if (!$existing) {
// INSERT
$data['created_at'] = current_time('mysql');
$this->wpdb->insert(
$this->tableName,
$data,
['%s', '%s', '%s', '%s']
);
} else {
// UPDATE
$this->wpdb->update(
$this->tableName,
$data,
['component_name' => $name->value()],
['%s', '%s', '%s'],
['%s']
);
}
}
/**
* Verificar si existen defaults para un componente
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool
{
$sql = $this->wpdb->prepare(
"SELECT COUNT(*) FROM {$this->tableName} WHERE component_name = %s",
$name->value()
);
return (int) $this->wpdb->get_var($sql) > 0;
}
/**
* Obtener todos los defaults
*
* @return array<string, ComponentConfiguration> Array asociativo nombre => configuración
*/
public function findAll(): array
{
$sql = "SELECT component_name, default_schema FROM {$this->tableName}";
$rows = $this->wpdb->get_results($sql, ARRAY_A);
$defaults = [];
foreach ($rows as $row) {
$schema = json_decode($row['default_schema'], true) ?? [];
$defaults[$row['component_name']] = ComponentConfiguration::fromArray($schema);
}
return $defaults;
}
/**
* Eliminar defaults de un componente
*
* @param ComponentName $name
* @return bool True si se eliminó, false si no existía
*/
public function delete(ComponentName $name): bool
{
$result = $this->wpdb->delete(
$this->tableName,
['component_name' => $name->value()],
['%s']
);
return $result !== false && $result > 0;
}
/**
* Obtener schema/defaults de un componente (método legacy)
*
* @deprecated Use getByName() instead
* @param string $componentName
* @return array|null Schema del componente o null si no existe
*/
public function find(string $componentName): ?array
{
$name = new ComponentName($componentName);
if (!$this->exists($name)) {
return null;
}
$config = $this->getByName($name);
return $config->all();
}
/**
* Guardar schema/defaults de un componente (método legacy)
*
* @deprecated Use save() instead
* @param string $componentName
* @param array $schema
* @return bool
*/
public function saveDefaults(string $componentName, array $schema): bool
{
$name = new ComponentName($componentName);
$config = ComponentConfiguration::fromArray($schema);
$this->save($name, $config);
return true;
}
/**
* Actualizar schema existente (método legacy)
*
* @deprecated Use save() instead
* @param string $componentName
* @param array $schema
* @return bool
*/
public function updateDefaults(string $componentName, array $schema): bool
{
return $this->saveDefaults($componentName, $schema);
}
/**
* Eliminar defaults de un componente (método legacy)
*
* @deprecated Use delete() instead
* @param string $componentName
* @return bool
*/
public function deleteDefaults(string $componentName): bool
{
$name = new ComponentName($componentName);
return $this->delete($name);
}
}

View File

@@ -0,0 +1,133 @@
# Capa de Infraestructura
## 📋 Propósito
La **Capa de Infraestructura** contiene las implementaciones concretas de las interfaces definidas en el Dominio. Conecta la lógica de negocio con el mundo exterior (WordPress, MySQL, cache, HTTP).
## 🎯 Responsabilidades
- ✅ Implementar interfaces del Dominio
- ✅ Conectar con frameworks (WordPress)
- ✅ Persistencia (MySQL via wpdb)
- ✅ Cache (WordPress Transients)
- ✅ HTTP (AJAX endpoints)
- ✅ Contiene detalles de implementación
## 📦 Estructura
```
Infrastructure/
├── Persistence/
│ └── WordPress/
│ ├── WordPressComponentRepository.php (MySQL)
│ └── WordPressDefaultsRepository.php (Schemas)
├── Services/
│ ├── WordPressValidationService.php (Validación)
│ ├── WordPressCacheService.php (Transients)
│ ├── SchemaSyncService.php (JSON → BD)
│ └── CleanupService.php (Limpieza)
├── API/
│ └── WordPress/
│ └── AjaxController.php (Endpoints AJAX)
├── Facades/
│ └── ComponentManager.php (API unificada)
├── DI/
│ └── DIContainer.php (Dependency Injection)
└── README.md
```
## 🔌 Implementaciones
### Repositories
#### WordPressComponentRepository
Persiste componentes en `wp_roi_theme_components`.
**Métodos**:
- `save(Component)`: INSERT o UPDATE
- `findByName(string)`: Buscar por nombre
- `findAll()`: Obtener todos
- `delete(string)`: Eliminar
#### WordPressDefaultsRepository
Gestiona schemas en `wp_roi_theme_defaults`.
### Services
#### WordPressValidationService
Valida datos contra schemas.
**Estrategia**:
1. Obtener schema de BD
2. Validar estructura
3. Sanitizar con funciones WordPress
#### WordPressCacheService
Cache con WordPress Transients API.
**TTL default**: 1 hora (3600 segundos)
#### SchemaSyncService
Sincroniza schemas desde JSON a BD.
#### CleanupService
Elimina componentes obsoletos.
### API
#### AjaxController
Endpoints AJAX de WordPress.
**Endpoints disponibles**:
- `roi_theme_save_component` (POST)
- `roi_theme_get_component` (GET)
- `roi_theme_delete_component` (POST)
- `roi_theme_sync_schema` (POST)
**Seguridad**:
- Nonce verification
- Capability check (manage_options)
### Facade
#### ComponentManager
API unificada para todo el sistema.
**Uso**:
```php
$manager = new ComponentManager($container);
$result = $manager->saveComponent('top_bar', $data);
```
## 📐 Principios Arquitectónicos
### Dependency Inversion
Infrastructure implementa interfaces del Domain:
```php
class WordPressComponentRepository implements ComponentRepositoryInterface
{
// Implementación específica de WordPress
}
```
### Separación de Concerns
- **Repositories**: Solo persistencia
- **Services**: Lógica de infraestructura
- **Controller**: Solo HTTP
- **Facade**: Orquestación simple
## 🧪 Testing
Tests de **integración** (usan BD real de WordPress):
```bash
vendor\bin\phpunit tests\Integration\Infrastructure
```
## 🔗 Referencias
- Domain Layer: `../Domain/README.md`
- Application Layer: `../Application/README.md`

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Services;
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressComponentRepository;
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
/**
* CleanupService - Limpieza de componentes obsoletos
*
* RESPONSABILIDAD: Eliminar componentes que ya no existen en schema
*
* @package ROITheme\Infrastructure\Services
*/
final class CleanupService
{
public function __construct(
private WordPressComponentRepository $componentRepository,
private WordPressDefaultsRepository $defaultsRepository
) {}
/**
* Eliminar componentes que no tienen schema
*
* @return array ['removed' => array]
*/
public function removeObsolete(): array
{
// Obtener todos los componentes actuales
$components = $this->componentRepository->findAll();
// Obtener schemas disponibles
$schemas = $this->defaultsRepository->findAll();
$validNames = array_keys($schemas);
$removed = [];
foreach ($components as $component) {
$name = $component->name()->value();
// Si el componente no tiene schema, es obsoleto
if (!in_array($name, $validNames)) {
$this->componentRepository->delete($name);
$removed[] = $name;
}
}
return ['removed' => $removed];
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Services;
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
/**
* SchemaSyncService - Sincronizar schemas JSON → BD
*
* RESPONSABILIDAD: Leer schemas desde archivos JSON y sincronizar con BD
*
* FLUJO:
* 1. Leer archivos JSON de schemas
* 2. Comparar con BD actual
* 3. Agregar/Actualizar/Eliminar según diferencias
*
* @package ROITheme\Infrastructure\Services
*/
final class SchemaSyncService
{
private string $schemasPath;
public function __construct(
private WordPressDefaultsRepository $defaultsRepository,
string $schemasPath
) {
$this->schemasPath = rtrim($schemasPath, '/');
}
/**
* Sincronizar todos los schemas
*
* @return array ['success' => bool, 'data' => array]
*/
public function syncAll(): array
{
try {
// 1. Leer schemas desde JSON
$schemas = $this->readSchemasFromJson();
if (empty($schemas)) {
return [
'success' => false,
'error' => 'No schemas found in JSON files'
];
}
// 2. Obtener schemas actuales de BD
$currentSchemas = $this->defaultsRepository->findAll();
// 3. Determinar cambios
$schemaNames = array_keys($schemas);
$currentNames = array_keys($currentSchemas);
$toAdd = array_diff($schemaNames, $currentNames);
$toUpdate = array_intersect($schemaNames, $currentNames);
$toDelete = array_diff($currentNames, $schemaNames);
// 4. Aplicar cambios
$added = [];
$updated = [];
$deleted = [];
foreach ($toAdd as $name) {
$this->defaultsRepository->saveDefaults($name, $schemas[$name]);
$added[] = $name;
}
foreach ($toUpdate as $name) {
$this->defaultsRepository->updateDefaults($name, $schemas[$name]);
$updated[] = $name;
}
foreach ($toDelete as $name) {
$this->defaultsRepository->deleteDefaults($name);
$deleted[] = $name;
}
return [
'success' => true,
'data' => [
'added' => $added,
'updated' => $updated,
'deleted' => $deleted
]
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
/**
* Sincronizar un componente específico
*
* @param string $componentName
* @return array
*/
public function syncComponent(string $componentName): array
{
try {
$schemas = $this->readSchemasFromJson();
if (!isset($schemas[$componentName])) {
return [
'success' => false,
'error' => "Schema not found for: {$componentName}"
];
}
$this->defaultsRepository->saveDefaults($componentName, $schemas[$componentName]);
return [
'success' => true,
'data' => ['synced' => $componentName]
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
/**
* Leer schemas desde archivos JSON
*
* @return array Array asociativo [componentName => schema]
*/
private function readSchemasFromJson(): array
{
$schemasFile = $this->schemasPath . '/components-schema.json';
if (!file_exists($schemasFile)) {
throw new \RuntimeException("Schemas file not found: {$schemasFile}");
}
$content = file_get_contents($schemasFile);
$schemas = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON: ' . json_last_error_msg());
}
return $schemas;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Services;
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
/**
* WordPressCacheService - Cache con Transients API
*
* RESPONSABILIDAD: Gestionar cache de componentes
*
* IMPLEMENTACIÓN: WordPress Transients
* - get_transient()
* - set_transient()
* - delete_transient()
*
* VENTAJAS:
* - Compatible con object cache (Redis, Memcached)
* - Expiración automática
* - API simple
*
* @package ROITheme\Infrastructure\Services
*/
final class WordPressCacheService implements CacheServiceInterface
{
private const PREFIX = 'roi_theme_';
public function __construct(
private \wpdb $wpdb
) {}
/**
* Obtener valor del cache
*
* @param string $key Clave del cache
* @return mixed|null Valor o null si no existe/expiró
*/
public function get(string $key): mixed
{
$transient = get_transient($this->getFullKey($key));
// WordPress devuelve false si no existe
return $transient === false ? null : $transient;
}
/**
* Guardar valor en cache
*
* @param string $key Clave del cache
* @param mixed $value Valor a guardar
* @param int $expiration Tiempo de vida en segundos (default 1 hora)
* @return bool True si guardó exitosamente
*/
public function set(string $key, mixed $value, int $expiration = 3600): bool
{
return set_transient($this->getFullKey($key), $value, $expiration);
}
/**
* Eliminar entrada de cache
*
* @param string $key Clave del cache
* @return bool True si eliminó exitosamente
*/
public function delete(string $key): bool
{
return $this->invalidate($key);
}
/**
* Limpiar todo el cache
*
* @return bool
*/
public function flush(): bool
{
return $this->invalidateAll();
}
/**
* Invalidar (eliminar) entrada de cache
*
* @param string $key Clave del cache
* @return bool True si eliminó exitosamente
*/
public function invalidate(string $key): bool
{
return delete_transient($this->getFullKey($key));
}
/**
* Invalidar todo el cache de componentes
*
* @return bool
*/
public function invalidateAll(): bool
{
// Obtener todos los componentes
$components = $this->wpdb->get_col(
"SELECT DISTINCT component_name FROM {$this->wpdb->prefix}roi_theme_components"
);
$success = true;
foreach ($components as $componentName) {
$result = $this->invalidate("component_{$componentName}");
$success = $success && $result;
}
return $success;
}
/**
* Obtener clave completa con prefijo
*
* @param string $key
* @return string
*/
private function getFullKey(string $key): string
{
return self::PREFIX . $key;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace ROITheme\Component\Infrastructure\Services;
use ROITheme\Shared\Domain\ValidationServiceInterface;
use ROITheme\Shared\Domain\ValidationResult;
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
/**
* WordPressValidationService - Validación contra schemas
*
* RESPONSABILIDAD: Validar y sanitizar datos de componentes
*
* ESTRATEGIA:
* 1. Obtener schema del componente desde BD
* 2. Validar estructura contra schema
* 3. Sanitizar datos usando funciones de WordPress
*
* @package ROITheme\Infrastructure\Services
*/
final class WordPressValidationService implements ValidationServiceInterface
{
public function __construct(
private WordPressDefaultsRepository $defaultsRepository
) {}
/**
* Validar datos contra schema
*
* @param array $data Datos a validar
* @param string $componentName Nombre del componente (para obtener schema)
* @return ValidationResult
*/
public function validate(array $data, string $componentName): ValidationResult
{
// 1. Obtener schema
$schema = $this->defaultsRepository->find($componentName);
if ($schema === null) {
return ValidationResult::failure([
"Schema not found for component: {$componentName}"
]);
}
// 2. Sanitizar datos primero
$sanitized = $this->sanitize($data, $componentName);
// 3. Validar estructura
$errors = [];
foreach ($sanitized as $groupName => $fields) {
// Verificar que el grupo existe en schema
if (!isset($schema[$groupName])) {
$errors[$groupName] = "Unknown group: {$groupName}";
continue;
}
// Validar cada campo del grupo
if (is_array($fields)) {
foreach ($fields as $key => $value) {
if (!isset($schema[$groupName][$key])) {
$errors["{$groupName}.{$key}"] = "Unknown field: {$groupName}.{$key}";
}
// Validaciones adicionales pueden agregarse aquí
// Por ejemplo, validar tipos, rangos, formatos, etc.
}
}
}
if (!empty($errors)) {
return ValidationResult::failure($errors);
}
return ValidationResult::success($sanitized);
}
/**
* Sanitizar datos recursivamente
*
* Usa funciones de WordPress según el tipo de dato
*
* @param array $data Datos a sanitizar
* @param string $componentName Nombre del componente
* @return array Datos sanitizados
*/
public function sanitize(array $data, string $componentName): array
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
// Recursivo para arrays anidados
$sanitized[$key] = $this->sanitizeValue($value);
} else {
$sanitized[$key] = $this->sanitizeValue($value);
}
}
return $sanitized;
}
/**
* Sanitizar un valor individual
*
* @param mixed $value
* @return mixed
*/
private function sanitizeValue(mixed $value): mixed
{
if (is_array($value)) {
$sanitized = [];
foreach ($value as $k => $v) {
$sanitized[$k] = $this->sanitizeValue($v);
}
return $sanitized;
}
if (is_bool($value)) {
return (bool) $value;
}
if (is_numeric($value)) {
return is_float($value) ? (float) $value : (int) $value;
}
if (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
return esc_url_raw($value);
}
if (is_string($value)) {
return sanitize_text_field($value);
}
return $value;
}
/**
* Validar una URL
*
* @param string $url
* @return bool
*/
public function isValidUrl(string $url): bool
{
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
/**
* Validar un color hexadecimal
*
* @param string $color
* @return bool
*/
public function isValidColor(string $color): bool
{
return preg_match('/^#[0-9A-F]{6}$/i', $color) === 1;
}
/**
* Validar nombre de componente
*
* @param string $name
* @return bool
*/
public function isValidComponentName(string $name): bool
{
// Solo letras minúsculas, números y guiones bajos
return preg_match('/^[a-z0-9_]+$/', $name) === 1;
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace ROITheme\ContactFormSection\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class ContactFormSectionFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder contact-form-builder">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#section-tab" type="button">Sección</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#contact-info-tab" type="button">Información</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#form-tab" type="button">Formulario</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="section-tab"><?php echo $this->buildSectionTab(); ?></div>
<div class="tab-pane fade" id="contact-info-tab"><?php echo $this->buildContactInfoTab(); ?></div>
<div class="tab-pane fade" id="form-tab"><?php echo $this->buildFormTab(); ?></div>
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildSectionTab(): string
{
$showSection = $this->data['section']['show_section'] ?? true;
$sectionTitle = $this->data['section']['section_title'] ?? '¿Tienes alguna pregunta?';
$sectionSubtitle = $this->data['section']['section_subtitle'] ?? '';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_section" name="section[show_section]" value="1" <?php checked($showSection, true); ?>>
<label class="form-check-label" for="show_section"><strong>Mostrar sección de contacto</strong></label>
</div>
</div>
<div class="form-group mb-4">
<label for="section_title"><strong>Título de la sección</strong></label>
<input type="text" class="form-control" id="section_title" name="section[section_title]" value="<?php echo esc_attr($sectionTitle); ?>" required>
</div>
<div class="form-group mb-4">
<label for="section_subtitle"><strong>Subtítulo</strong></label>
<textarea class="form-control" id="section_subtitle" name="section[section_subtitle]" rows="2"><?php echo esc_textarea($sectionSubtitle); ?></textarea>
</div>
<?php
return ob_get_clean();
}
private function buildContactInfoTab(): string
{
$contactInfo = $this->data['contact_info'] ?? [];
ob_start();
?>
<div class="card mb-3">
<div class="card-header">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="phone_enabled" name="contact_info[phone_enabled]" value="1" <?php checked($contactInfo['phone_enabled'] ?? true, true); ?>>
<label class="form-check-label" for="phone_enabled"><strong>Teléfono</strong></label>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="phone_label">Etiqueta</label>
<input type="text" class="form-control" id="phone_label" name="contact_info[phone_label]" value="<?php echo esc_attr($contactInfo['phone_label'] ?? 'Teléfono'); ?>">
</div>
<div class="col-md-6 mb-3">
<label for="phone_value">Número</label>
<input type="text" class="form-control" id="phone_value" name="contact_info[phone_value]" value="<?php echo esc_attr($contactInfo['phone_value'] ?? '+52 55 1234 5678'); ?>">
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="email_enabled" name="contact_info[email_enabled]" value="1" <?php checked($contactInfo['email_enabled'] ?? true, true); ?>>
<label class="form-check-label" for="email_enabled"><strong>Email</strong></label>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="email_label">Etiqueta</label>
<input type="text" class="form-control" id="email_label" name="contact_info[email_label]" value="<?php echo esc_attr($contactInfo['email_label'] ?? 'Email'); ?>">
</div>
<div class="col-md-6 mb-3">
<label for="email_value">Dirección</label>
<input type="email" class="form-control" id="email_value" name="contact_info[email_value]" value="<?php echo esc_attr($contactInfo['email_value'] ?? 'contacto@example.com'); ?>">
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="location_enabled" name="contact_info[location_enabled]" value="1" <?php checked($contactInfo['location_enabled'] ?? true, true); ?>>
<label class="form-check-label" for="location_enabled"><strong>Ubicación</strong></label>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="location_label">Etiqueta</label>
<input type="text" class="form-control" id="location_label" name="contact_info[location_label]" value="<?php echo esc_attr($contactInfo['location_label'] ?? 'Ubicación'); ?>">
</div>
<div class="col-md-6 mb-3">
<label for="location_value">Ubicación</label>
<input type="text" class="form-control" id="location_value" name="contact_info[location_value]" value="<?php echo esc_attr($contactInfo['location_value'] ?? 'Ciudad de México, México'); ?>">
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildFormTab(): string
{
$form = $this->data['form'] ?? [];
ob_start();
?>
<div class="form-group mb-4">
<label for="submit_button_text"><strong>Texto del botón de envío</strong></label>
<input type="text" class="form-control" id="submit_button_text" name="form[submit_button_text]" value="<?php echo esc_attr($form['submit_button_text'] ?? 'Enviar Mensaje'); ?>" required>
</div>
<div class="form-group mb-4">
<label for="submit_button_icon"><strong>Ícono del botón</strong></label>
<input type="text" class="form-control" id="submit_button_icon" name="form[submit_button_icon]" value="<?php echo esc_attr($form['submit_button_icon'] ?? 'bi-send-fill'); ?>">
<small class="form-text text-muted">Clase de Bootstrap Icons</small>
</div>
<div class="form-group mb-4">
<label for="success_message"><strong>Mensaje de éxito</strong></label>
<textarea class="form-control" id="success_message" name="form[success_message]" rows="2"><?php echo esc_textarea($form['success_message'] ?? '¡Gracias! Tu mensaje ha sido enviado correctamente.'); ?></textarea>
</div>
<div class="form-group mb-4">
<label for="error_message"><strong>Mensaje de error</strong></label>
<textarea class="form-control" id="error_message" name="form[error_message]" rows="2"><?php echo esc_textarea($form['error_message'] ?? 'Hubo un error al enviar el mensaje.'); ?></textarea>
</div>
<div class="form-group mb-4">
<label for="to_email"><strong>Email de destino</strong></label>
<input type="email" class="form-control" id="to_email" name="form[to_email]" value="<?php echo esc_attr($form['to_email'] ?? ''); ?>">
<small class="form-text text-muted">Deja vacío para usar el email del administrador</small>
</div>
<?php
return ob_get_clean();
}
private function buildStylesTab(): string
{
$styles = $this->data['styles'] ?? [];
ob_start();
?>
<div class="form-group mb-4">
<label for="background_color"><strong>Clase de fondo</strong></label>
<input type="text" class="form-control" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($styles['background_color'] ?? 'bg-secondary bg-opacity-25'); ?>">
<small class="form-text text-muted">Clase de Bootstrap (ej: bg-light, bg-secondary bg-opacity-25)</small>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<label for="icon_color"><strong>Color de íconos</strong></label>
<input type="color" class="form-control form-control-color" id="icon_color" name="styles[icon_color]" value="<?php echo esc_attr($styles['icon_color'] ?? '#FF8600'); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="button_bg_color"><strong>Color del botón</strong></label>
<input type="color" class="form-control form-control-color" id="button_bg_color" name="styles[button_bg_color]" value="<?php echo esc_attr($styles['button_bg_color'] ?? '#FF8600'); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="button_hover_bg"><strong>Color del botón (hover)</strong></label>
<input type="color" class="form-control form-control-color" id="button_hover_bg" name="styles[button_hover_bg]" value="<?php echo esc_attr($styles['button_hover_bg'] ?? '#FF6B00'); ?>">
</div>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace ROITheme\ContactFormSection\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class ContactFormSectionRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
if (!($data['section']['show_section'] ?? true)) {
return '';
}
$componentId = $component->getId();
$sectionTitle = $data['section']['section_title'] ?? '¿Tienes alguna pregunta?';
$sectionSubtitle = $data['section']['section_subtitle'] ?? 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.';
$backgroundClass = $data['styles']['background_color'] ?? 'bg-secondary bg-opacity-25';
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
ob_start();
?>
<?php if (!empty($customStyles)): ?>
<style>
<?php echo $customStyles; ?>
</style>
<?php endif; ?>
<section id="<?php echo esc_attr($componentId); ?>" class="contact-form-section <?php echo esc_attr($backgroundClass); ?> py-5">
<div class="container">
<div class="row justify-content-center mb-5">
<div class="col-lg-8 text-center">
<h2 class="h2 mb-3"><?php echo esc_html($sectionTitle); ?></h2>
<?php if (!empty($sectionSubtitle)): ?>
<p class="lead text-muted"><?php echo esc_html($sectionSubtitle); ?></p>
<?php endif; ?>
</div>
</div>
<div class="row g-4">
<div class="col-lg-5">
<?php echo $this->renderContactInfo($data); ?>
</div>
<div class="col-lg-7">
<?php echo $this->renderForm($componentId, $data); ?>
</div>
</div>
</div>
</section>
<?php
return ob_get_clean();
}
private function renderContactInfo(array $data): string
{
$contactInfo = $data['contact_info'] ?? [];
ob_start();
?>
<div class="contact-info h-100">
<h3 class="h4 mb-4">Información de contacto</h3>
<div class="contact-items">
<?php if ($contactInfo['phone_enabled'] ?? true): ?>
<div class="contact-item mb-4">
<div class="d-flex align-items-start">
<i class="bi bi-telephone-fill contact-icon me-3"></i>
<div>
<h4 class="h6 mb-1"><?php echo esc_html($contactInfo['phone_label'] ?? 'Teléfono'); ?></h4>
<p class="mb-0"><?php echo esc_html($contactInfo['phone_value'] ?? '+52 55 1234 5678'); ?></p>
</div>
</div>
</div>
<?php endif; ?>
<?php if ($contactInfo['email_enabled'] ?? true): ?>
<div class="contact-item mb-4">
<div class="d-flex align-items-start">
<i class="bi bi-envelope-fill contact-icon me-3"></i>
<div>
<h4 class="h6 mb-1"><?php echo esc_html($contactInfo['email_label'] ?? 'Email'); ?></h4>
<p class="mb-0"><?php echo esc_html($contactInfo['email_value'] ?? 'contacto@example.com'); ?></p>
</div>
</div>
</div>
<?php endif; ?>
<?php if ($contactInfo['location_enabled'] ?? true): ?>
<div class="contact-item mb-4">
<div class="d-flex align-items-start">
<i class="bi bi-geo-alt-fill contact-icon me-3"></i>
<div>
<h4 class="h6 mb-1"><?php echo esc_html($contactInfo['location_label'] ?? 'Ubicación'); ?></h4>
<p class="mb-0"><?php echo esc_html($contactInfo['location_value'] ?? 'Ciudad de México, México'); ?></p>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php
return ob_get_clean();
}
private function renderForm(string $componentId, array $data): string
{
$submitButtonText = $data['form']['submit_button_text'] ?? 'Enviar Mensaje';
$submitButtonIcon = $data['form']['submit_button_icon'] ?? 'bi-send-fill';
$successMessage = $data['form']['success_message'] ?? '¡Gracias! Tu mensaje ha sido enviado correctamente.';
$errorMessage = $data['form']['error_message'] ?? 'Hubo un error al enviar el mensaje.';
ob_start();
?>
<div class="contact-form-wrapper">
<form id="contact-form-<?php echo esc_attr($componentId); ?>" class="contact-form needs-validation" novalidate>
<?php wp_nonce_field('roi_contact_form_' . $componentId, 'roi_contact_nonce'); ?>
<input type="hidden" name="action" value="roi_contact_form_submit">
<input type="hidden" name="component_id" value="<?php echo esc_attr($componentId); ?>">
<div class="row g-3">
<div class="col-md-6">
<input type="text" class="form-control" name="fullName" placeholder="Nombre completo *" required>
<div class="invalid-feedback">Por favor ingresa tu nombre</div>
</div>
<div class="col-md-6">
<input type="text" class="form-control" name="company" placeholder="Empresa">
</div>
<div class="col-md-6">
<input type="tel" class="form-control" name="whatsapp" placeholder="WhatsApp *" required>
<div class="invalid-feedback">Por favor ingresa tu WhatsApp</div>
</div>
<div class="col-md-6">
<input type="email" class="form-control" name="email" placeholder="Correo electrónico *" required>
<div class="invalid-feedback">Por favor ingresa un email válido</div>
</div>
<div class="col-12">
<textarea class="form-control" name="comments" rows="4" placeholder="¿En qué podemos ayudarte?"></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi <?php echo esc_attr($submitButtonIcon); ?> me-2"></i>
<?php echo esc_html($submitButtonText); ?>
</button>
</div>
</div>
<div class="alert alert-success mt-3 d-none" role="alert" id="form-success-<?php echo esc_attr($componentId); ?>">
<?php echo esc_html($successMessage); ?>
</div>
<div class="alert alert-danger mt-3 d-none" role="alert" id="form-error-<?php echo esc_attr($componentId); ?>">
<?php echo esc_html($errorMessage); ?>
</div>
</form>
</div>
<script>
(function() {
const form = document.getElementById('contact-form-<?php echo esc_js($componentId); ?>');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!form.checkValidity()) {
e.stopPropagation();
form.classList.add('was-validated');
return;
}
const formData = new FormData(form);
const submitBtn = form.querySelector('button[type="submit"]');
const successAlert = document.getElementById('form-success-<?php echo esc_js($componentId); ?>');
const errorAlert = document.getElementById('form-error-<?php echo esc_js($componentId); ?>');
submitBtn.disabled = true;
successAlert.classList.add('d-none');
errorAlert.classList.add('d-none');
fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
successAlert.classList.remove('d-none');
form.reset();
form.classList.remove('was-validated');
} else {
errorAlert.classList.remove('d-none');
}
})
.catch(error => {
errorAlert.classList.remove('d-none');
})
.finally(() => {
submitBtn.disabled = false;
});
});
})();
</script>
<?php
return ob_get_clean();
}
private function generateCustomStyles(string $componentId, array $styles): string
{
if (empty($styles)) {
return '';
}
$css = [];
if (isset($styles['icon_color'])) {
$css[] = "#$componentId .contact-icon { color: {$styles['icon_color']}; font-size: 1.5rem; }";
}
if (isset($styles['button_bg_color'])) {
$css[] = "#$componentId .btn-primary { background-color: {$styles['button_bg_color']}; border-color: {$styles['button_bg_color']}; }";
}
if (isset($styles['button_hover_bg'])) {
$css[] = "#$componentId .btn-primary:hover { background-color: {$styles['button_hover_bg']}; border-color: {$styles['button_hover_bg']}; }";
}
return implode("\n", $css);
}
public function supports(string $componentType): bool
{
return $componentType === 'contact-form-section';
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace ROITheme\ContactModal\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class ContactModalFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder contact-modal-form-builder">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#general-tab" type="button">General</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#fields-tab" type="button">Campos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#messages-tab" type="button">Mensajes</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#settings-tab" type="button">Configuración</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="general-tab"><?php echo $this->buildGeneralTab(); ?></div>
<div class="tab-pane fade" id="fields-tab"><?php echo $this->buildFieldsTab(); ?></div>
<div class="tab-pane fade" id="messages-tab"><?php echo $this->buildMessagesTab(); ?></div>
<div class="tab-pane fade" id="settings-tab"><?php echo $this->buildSettingsTab(); ?></div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildGeneralTab(): string
{
$general = $this->data['general'] ?? [];
$modalTitle = $general['modal_title'] ?? '¿Tienes alguna pregunta?';
$modalDescription = $general['modal_description'] ?? '';
$submitButton = $this->data['submit_button'] ?? [];
$buttonText = $submitButton['text'] ?? 'Enviar Mensaje';
$buttonIcon = $submitButton['icon'] ?? 'bi-send-fill';
ob_start();
?>
<div class="form-group mb-4">
<label for="modal_title"><strong>Título del modal</strong></label>
<input type="text" class="form-control" id="modal_title" name="general[modal_title]" value="<?php echo esc_attr($modalTitle); ?>" required>
<small class="form-text text-muted">Título que aparece en el encabezado del modal</small>
</div>
<div class="form-group mb-4">
<label for="modal_description"><strong>Descripción</strong></label>
<textarea class="form-control" id="modal_description" name="general[modal_description]" rows="3"><?php echo esc_textarea($modalDescription); ?></textarea>
<small class="form-text text-muted">Texto descriptivo debajo del título</small>
</div>
<hr>
<h5 class="mb-3">Botón de Envío</h5>
<div class="form-group mb-4">
<label for="submit_button_text"><strong>Texto del botón</strong></label>
<input type="text" class="form-control" id="submit_button_text" name="submit_button[text]" value="<?php echo esc_attr($buttonText); ?>" required>
<small class="form-text text-muted">Texto que aparece en el botón de envío</small>
</div>
<div class="form-group mb-4">
<label for="submit_button_icon"><strong>Ícono del botón</strong></label>
<input type="text" class="form-control" id="submit_button_icon" name="submit_button[icon]" value="<?php echo esc_attr($buttonIcon); ?>">
<small class="form-text text-muted">Clase de Bootstrap Icons (ej: bi-send-fill). <a href="https://icons.getbootstrap.com/" target="_blank">Ver íconos</a></small>
</div>
<?php
return ob_get_clean();
}
private function buildFieldsTab(): string
{
$formFields = $this->data['form_fields'] ?? [];
ob_start();
?>
<p class="text-muted mb-4">
Configure las etiquetas, placeholders y si cada campo es obligatorio o no.
</p>
<?php echo $this->buildFieldConfig('fullName', 'Nombre Completo', $formFields['fullName'] ?? []); ?>
<?php echo $this->buildFieldConfig('company', 'Empresa', $formFields['company'] ?? []); ?>
<?php echo $this->buildFieldConfig('whatsapp', 'WhatsApp', $formFields['whatsapp'] ?? []); ?>
<?php echo $this->buildFieldConfig('email', 'Correo Electrónico', $formFields['email'] ?? []); ?>
<?php echo $this->buildFieldConfig('comments', 'Comentarios', $formFields['comments'] ?? [], true); ?>
<?php
return ob_get_clean();
}
private function buildFieldConfig(string $fieldKey, string $fieldLabel, array $fieldData, bool $isTextarea = false): string
{
$label = $fieldData['label'] ?? '';
$placeholder = $fieldData['placeholder'] ?? '';
$required = $fieldData['required'] ?? false;
$rows = $fieldData['rows'] ?? 4;
ob_start();
?>
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><?php echo esc_html($fieldLabel); ?></h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5 mb-3">
<label for="<?php echo esc_attr($fieldKey); ?>_label">Etiqueta</label>
<input type="text" class="form-control" id="<?php echo esc_attr($fieldKey); ?>_label" name="form_fields[<?php echo esc_attr($fieldKey); ?>][label]" value="<?php echo esc_attr($label); ?>" required>
</div>
<div class="col-md-5 mb-3">
<label for="<?php echo esc_attr($fieldKey); ?>_placeholder">Placeholder</label>
<input type="text" class="form-control" id="<?php echo esc_attr($fieldKey); ?>_placeholder" name="form_fields[<?php echo esc_attr($fieldKey); ?>][placeholder]" value="<?php echo esc_attr($placeholder); ?>">
</div>
<?php if ($isTextarea): ?>
<div class="col-md-2 mb-3">
<label for="<?php echo esc_attr($fieldKey); ?>_rows">Filas</label>
<input type="number" class="form-control" id="<?php echo esc_attr($fieldKey); ?>_rows" name="form_fields[<?php echo esc_attr($fieldKey); ?>][rows]" value="<?php echo esc_attr($rows); ?>" min="2" max="10">
</div>
<?php endif; ?>
<div class="col-md-<?php echo $isTextarea ? '12' : '2'; ?>">
<div class="form-check form-switch" style="<?php echo $isTextarea ? '' : 'margin-top: 32px;'; ?>">
<input type="checkbox" class="form-check-input" id="<?php echo esc_attr($fieldKey); ?>_required" name="form_fields[<?php echo esc_attr($fieldKey); ?>][required]" value="1" <?php checked($required, true); ?>>
<label class="form-check-label" for="<?php echo esc_attr($fieldKey); ?>_required">
Campo requerido
</label>
</div>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildMessagesTab(): string
{
$messages = $this->data['messages'] ?? [];
$success = $messages['success'] ?? 'Mensaje enviado exitosamente. Te responderemos pronto.';
$error = $messages['error'] ?? 'Error al enviar el mensaje. Por favor intenta nuevamente.';
$validationError = $messages['validation_error'] ?? 'Por favor completa todos los campos requeridos.';
ob_start();
?>
<p class="text-muted mb-4">
Configure los mensajes que se mostrarán al usuario según el resultado del envío del formulario.
</p>
<div class="form-group mb-4">
<label for="message_success"><strong>Mensaje de éxito</strong></label>
<textarea class="form-control" id="message_success" name="messages[success]" rows="2" required><?php echo esc_textarea($success); ?></textarea>
<small class="form-text text-muted">Mensaje que se muestra cuando el formulario se envía correctamente</small>
</div>
<div class="form-group mb-4">
<label for="message_error"><strong>Mensaje de error</strong></label>
<textarea class="form-control" id="message_error" name="messages[error]" rows="2" required><?php echo esc_textarea($error); ?></textarea>
<small class="form-text text-muted">Mensaje que se muestra cuando ocurre un error al enviar</small>
</div>
<div class="form-group mb-4">
<label for="message_validation_error"><strong>Mensaje de validación</strong></label>
<textarea class="form-control" id="message_validation_error" name="messages[validation_error]" rows="2" required><?php echo esc_textarea($validationError); ?></textarea>
<small class="form-text text-muted">Mensaje que se muestra cuando faltan campos obligatorios</small>
</div>
<?php
return ob_get_clean();
}
private function buildSettingsTab(): string
{
$settings = $this->data['settings'] ?? [];
$modalId = $settings['modal_id'] ?? 'contactModal';
$formId = $settings['form_id'] ?? 'modalContactForm';
$ajaxAction = $settings['ajax_action'] ?? 'roi_contact_modal_submit';
$emailTo = $settings['email_to'] ?? '';
$emailSubject = $settings['email_subject'] ?? 'Nuevo mensaje de contacto desde el sitio web';
ob_start();
?>
<p class="text-muted mb-4">
Configuración técnica del modal y envío de emails.
</p>
<h5 class="mb-3">Configuración de Email</h5>
<div class="form-group mb-4">
<label for="email_to"><strong>Email de destino</strong></label>
<input type="email" class="form-control" id="email_to" name="settings[email_to]" value="<?php echo esc_attr($emailTo); ?>">
<small class="form-text text-muted">Email donde se recibirán los mensajes de contacto (vacío = admin email)</small>
</div>
<div class="form-group mb-4">
<label for="email_subject"><strong>Asunto del email</strong></label>
<input type="text" class="form-control" id="email_subject" name="settings[email_subject]" value="<?php echo esc_attr($emailSubject); ?>" required>
<small class="form-text text-muted">Asunto del email que se enviará</small>
</div>
<hr>
<h5 class="mb-3">Configuración Técnica</h5>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
<strong>Advertencia:</strong> Los siguientes campos no deben modificarse a menos que sepas lo que estás haciendo.
</div>
<div class="form-group mb-4">
<label for="modal_id"><strong>ID del modal</strong></label>
<input type="text" class="form-control" id="modal_id" name="settings[modal_id]" value="<?php echo esc_attr($modalId); ?>" readonly>
<small class="form-text text-muted">ID HTML del modal (no modificar)</small>
</div>
<div class="form-group mb-4">
<label for="form_id"><strong>ID del formulario</strong></label>
<input type="text" class="form-control" id="form_id" name="settings[form_id]" value="<?php echo esc_attr($formId); ?>" readonly>
<small class="form-text text-muted">ID HTML del formulario (no modificar)</small>
</div>
<div class="form-group mb-4">
<label for="ajax_action"><strong>Acción AJAX</strong></label>
<input type="text" class="form-control" id="ajax_action" name="settings[ajax_action]" value="<?php echo esc_attr($ajaxAction); ?>" readonly>
<small class="form-text text-muted">Nombre de la acción AJAX de WordPress (no modificar)</small>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace ROITheme\ContactModal\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class ContactModalRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
$componentId = $component->getId();
$modalId = $data['settings']['modal_id'] ?? 'contactModal';
$formId = $data['settings']['form_id'] ?? 'modalContactForm';
ob_start();
?>
<div class="modal fade" id="<?php echo esc_attr($modalId); ?>" tabindex="-1" aria-labelledby="<?php echo esc_attr($modalId); ?>Label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<?php echo $this->renderHeader($data, $modalId); ?>
<?php echo $this->renderBody($data, $formId, $componentId); ?>
</div>
</div>
</div>
<?php echo $this->renderScript($data, $formId, $modalId, $componentId); ?>
<?php
return ob_get_clean();
}
private function renderHeader(array $data, string $modalId): string
{
$title = $data['general']['modal_title'] ?? '¿Tienes alguna pregunta?';
ob_start();
?>
<div class="modal-header">
<h5 class="modal-title" id="<?php echo esc_attr($modalId); ?>Label">
<i class="bi bi-envelope-fill me-2" style="color: #FF8600;"></i>
<?php echo esc_html($title); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<?php
return ob_get_clean();
}
private function renderBody(array $data, string $formId, string $componentId): string
{
$description = $data['general']['modal_description'] ?? '';
$ajaxAction = $data['settings']['ajax_action'] ?? 'roi_contact_modal_submit';
ob_start();
?>
<div class="modal-body">
<?php if (!empty($description)): ?>
<p class="mb-4"><?php echo esc_html($description); ?></p>
<?php endif; ?>
<form id="<?php echo esc_attr($formId); ?>" method="post" class="needs-validation" novalidate>
<?php wp_nonce_field('roi_contact_modal_' . $componentId, 'contact_nonce'); ?>
<input type="hidden" name="action" value="<?php echo esc_attr($ajaxAction); ?>">
<input type="hidden" name="component_id" value="<?php echo esc_attr($componentId); ?>">
<div class="row g-3">
<?php echo $this->renderFormFields($data); ?>
<?php echo $this->renderSubmitButton($data); ?>
</div>
<div id="modal-form-message-<?php echo esc_attr($componentId); ?>" class="alert mt-3 d-none" role="alert"></div>
</form>
</div>
<?php
return ob_get_clean();
}
private function renderFormFields(array $data): string
{
$formFields = $data['form_fields'] ?? [];
$html = '';
// fullName
if (!empty($formFields['fullName'])) {
$html .= $this->renderTextField('fullName', 'modalFullName', $formFields['fullName'], 'col-md-6');
}
// company
if (!empty($formFields['company'])) {
$html .= $this->renderTextField('company', 'modalCompany', $formFields['company'], 'col-md-6');
}
// whatsapp
if (!empty($formFields['whatsapp'])) {
$html .= $this->renderTelField('whatsapp', 'modalWhatsapp', $formFields['whatsapp'], 'col-md-6');
}
// email
if (!empty($formFields['email'])) {
$html .= $this->renderEmailField('email', 'modalEmail', $formFields['email'], 'col-md-6');
}
// comments
if (!empty($formFields['comments'])) {
$html .= $this->renderTextareaField('comments', 'modalComments', $formFields['comments'], 'col-12');
}
return $html;
}
private function renderTextField(string $name, string $id, array $fieldData, string $colClass): string
{
$label = $fieldData['label'] ?? '';
$placeholder = $fieldData['placeholder'] ?? '';
$required = $fieldData['required'] ?? false;
ob_start();
?>
<div class="<?php echo esc_attr($colClass); ?>">
<label for="<?php echo esc_attr($id); ?>" class="form-label">
<?php echo esc_html($label); ?>
<?php if ($required): ?>
<span class="text-danger">*</span>
<?php endif; ?>
</label>
<input
type="text"
class="form-control"
id="<?php echo esc_attr($id); ?>"
name="<?php echo esc_attr($name); ?>"
placeholder="<?php echo esc_attr($placeholder); ?>"
<?php if ($required): ?>required<?php endif; ?>
>
<?php if ($required): ?>
<div class="invalid-feedback">Por favor completa este campo</div>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
private function renderTelField(string $name, string $id, array $fieldData, string $colClass): string
{
$label = $fieldData['label'] ?? '';
$placeholder = $fieldData['placeholder'] ?? '';
$required = $fieldData['required'] ?? false;
ob_start();
?>
<div class="<?php echo esc_attr($colClass); ?>">
<label for="<?php echo esc_attr($id); ?>" class="form-label">
<?php echo esc_html($label); ?>
<?php if ($required): ?>
<span class="text-danger">*</span>
<?php endif; ?>
</label>
<input
type="tel"
class="form-control"
id="<?php echo esc_attr($id); ?>"
name="<?php echo esc_attr($name); ?>"
placeholder="<?php echo esc_attr($placeholder); ?>"
<?php if ($required): ?>required<?php endif; ?>
>
<?php if ($required): ?>
<div class="invalid-feedback">Por favor completa este campo</div>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
private function renderEmailField(string $name, string $id, array $fieldData, string $colClass): string
{
$label = $fieldData['label'] ?? '';
$placeholder = $fieldData['placeholder'] ?? '';
$required = $fieldData['required'] ?? false;
ob_start();
?>
<div class="<?php echo esc_attr($colClass); ?>">
<label for="<?php echo esc_attr($id); ?>" class="form-label">
<?php echo esc_html($label); ?>
<?php if ($required): ?>
<span class="text-danger">*</span>
<?php endif; ?>
</label>
<input
type="email"
class="form-control"
id="<?php echo esc_attr($id); ?>"
name="<?php echo esc_attr($name); ?>"
placeholder="<?php echo esc_attr($placeholder); ?>"
<?php if ($required): ?>required<?php endif; ?>
>
<?php if ($required): ?>
<div class="invalid-feedback">Por favor ingresa un email válido</div>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
private function renderTextareaField(string $name, string $id, array $fieldData, string $colClass): string
{
$label = $fieldData['label'] ?? '';
$placeholder = $fieldData['placeholder'] ?? '';
$required = $fieldData['required'] ?? false;
$rows = $fieldData['rows'] ?? 4;
ob_start();
?>
<div class="<?php echo esc_attr($colClass); ?>">
<label for="<?php echo esc_attr($id); ?>" class="form-label">
<?php echo esc_html($label); ?>
<?php if ($required): ?>
<span class="text-danger">*</span>
<?php endif; ?>
</label>
<textarea
class="form-control"
id="<?php echo esc_attr($id); ?>"
name="<?php echo esc_attr($name); ?>"
rows="<?php echo esc_attr($rows); ?>"
placeholder="<?php echo esc_attr($placeholder); ?>"
<?php if ($required): ?>required<?php endif; ?>
></textarea>
<?php if ($required): ?>
<div class="invalid-feedback">Por favor completa este campo</div>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
private function renderSubmitButton(array $data): string
{
$buttonText = $data['submit_button']['text'] ?? 'Enviar Mensaje';
$buttonIcon = $data['submit_button']['icon'] ?? 'bi-send-fill';
ob_start();
?>
<div class="col-12">
<button type="submit" class="btn btn-primary w-100">
<i class="bi <?php echo esc_attr($buttonIcon); ?> me-2"></i>
<?php echo esc_html($buttonText); ?>
</button>
</div>
<?php
return ob_get_clean();
}
private function renderScript(array $data, string $formId, string $modalId, string $componentId): string
{
$messages = $data['messages'] ?? [];
$successMessage = $messages['success'] ?? 'Mensaje enviado exitosamente';
$errorMessage = $messages['error'] ?? 'Error al enviar el mensaje';
$buttonText = $data['submit_button']['text'] ?? 'Enviar Mensaje';
$buttonIcon = $data['submit_button']['icon'] ?? 'bi-send-fill';
ob_start();
?>
<script>
(function() {
const form = document.getElementById('<?php echo esc_js($formId); ?>');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!form.checkValidity()) {
e.stopPropagation();
form.classList.add('was-validated');
return;
}
const formData = new FormData(form);
const submitBtn = form.querySelector('button[type="submit"]');
const messageContainer = document.getElementById('modal-form-message-<?php echo esc_js($componentId); ?>');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enviando...';
messageContainer.classList.add('d-none');
fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
messageContainer.classList.remove('alert-danger');
messageContainer.classList.add('alert-success');
messageContainer.textContent = data.data?.message || '<?php echo esc_js($successMessage); ?>';
messageContainer.classList.remove('d-none');
form.reset();
form.classList.remove('was-validated');
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(document.getElementById('<?php echo esc_js($modalId); ?>'));
if (modal) {
modal.hide();
}
messageContainer.classList.add('d-none');
}, 2000);
} else {
messageContainer.classList.remove('alert-success');
messageContainer.classList.add('alert-danger');
messageContainer.textContent = data.data?.message || '<?php echo esc_js($errorMessage); ?>';
messageContainer.classList.remove('d-none');
}
})
.catch(error => {
messageContainer.classList.remove('alert-success');
messageContainer.classList.add('alert-danger');
messageContainer.textContent = '<?php echo esc_js($errorMessage); ?>';
messageContainer.classList.remove('d-none');
})
.finally(() => {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi <?php echo esc_js($buttonIcon); ?> me-2"></i><?php echo esc_js($buttonText); ?>';
});
});
// Limpiar mensajes al cerrar el modal
const modalElement = document.getElementById('<?php echo esc_js($modalId); ?>');
if (modalElement) {
modalElement.addEventListener('hidden.bs.modal', function() {
const messageContainer = document.getElementById('modal-form-message-<?php echo esc_js($componentId); ?>');
if (messageContainer) {
messageContainer.classList.add('d-none');
}
form.reset();
form.classList.remove('was-validated');
});
}
})();
</script>
<?php
return ob_get_clean();
}
public function supports(string $componentType): bool
{
return $componentType === 'contact-modal';
}
}

View File

@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace ROITheme\Footer\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class FooterFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder footer-form-builder">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#widgets-tab" type="button">Widgets</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#newsletter-tab" type="button">Newsletter</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#copyright-tab" type="button">Copyright</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#social-tab" type="button">Redes Sociales</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="widgets-tab"><?php echo $this->buildWidgetsTab(); ?></div>
<div class="tab-pane fade" id="newsletter-tab"><?php echo $this->buildNewsletterTab(); ?></div>
<div class="tab-pane fade" id="copyright-tab"><?php echo $this->buildCopyrightTab(); ?></div>
<div class="tab-pane fade" id="social-tab"><?php echo $this->buildSocialTab(); ?></div>
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
</div>
</div>
<script>
jQuery(document).ready(function($) {
// Función para añadir enlaces a los widgets
$('.add-widget-link').on('click', function(e) {
e.preventDefault();
const widgetKey = $(this).data('widget');
const container = $('#' + widgetKey + '-links-container');
const index = container.find('.widget-link-item').length;
const template = `
<div class="widget-link-item card mb-2">
<div class="card-body">
<div class="row">
<div class="col-md-5 mb-2">
<label>Texto del enlace</label>
<input type="text" class="form-control" name="${widgetKey}[links][${index}][text]" required>
</div>
<div class="col-md-5 mb-2">
<label>URL</label>
<input type="text" class="form-control" name="${widgetKey}[links][${index}][url]" required>
</div>
<div class="col-md-2 mb-2 d-flex align-items-end">
<button type="button" class="btn btn-danger btn-sm remove-widget-link w-100">
<i class="bi bi-trash"></i> Eliminar
</button>
</div>
</div>
</div>
</div>
`;
container.append(template);
});
// Función para eliminar enlaces
$(document).on('click', '.remove-widget-link', function(e) {
e.preventDefault();
$(this).closest('.widget-link-item').remove();
});
});
</script>
<?php
return ob_get_clean();
}
private function buildWidgetsTab(): string
{
ob_start();
?>
<div class="row">
<div class="col-md-4">
<?php echo $this->buildWidgetColumn('widget_1', 'Widget 1'); ?>
</div>
<div class="col-md-4">
<?php echo $this->buildWidgetColumn('widget_2', 'Widget 2'); ?>
</div>
<div class="col-md-4">
<?php echo $this->buildWidgetColumn('widget_3', 'Widget 3'); ?>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildWidgetColumn(string $widgetKey, string $label): string
{
$widgetData = $this->data[$widgetKey] ?? [];
$enabled = $widgetData['enabled'] ?? true;
$title = $widgetData['title'] ?? '';
$links = $widgetData['links'] ?? [];
ob_start();
?>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><?php echo esc_html($label); ?></h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="<?php echo esc_attr($widgetKey); ?>_enabled" name="<?php echo esc_attr($widgetKey); ?>[enabled]" value="1" <?php checked($enabled, true); ?>>
<label class="form-check-label" for="<?php echo esc_attr($widgetKey); ?>_enabled">
<strong>Activar este widget</strong>
</label>
</div>
</div>
<div class="form-group mb-3">
<label for="<?php echo esc_attr($widgetKey); ?>_title"><strong>Título del widget</strong></label>
<input type="text" class="form-control" id="<?php echo esc_attr($widgetKey); ?>_title" name="<?php echo esc_attr($widgetKey); ?>[title]" value="<?php echo esc_attr($title); ?>">
</div>
<div class="form-group mb-3">
<label><strong>Enlaces</strong></label>
<div id="<?php echo esc_attr($widgetKey); ?>-links-container">
<?php if (!empty($links)): ?>
<?php foreach ($links as $index => $link): ?>
<div class="widget-link-item card mb-2">
<div class="card-body">
<div class="row">
<div class="col-md-5 mb-2">
<label>Texto del enlace</label>
<input type="text" class="form-control" name="<?php echo esc_attr($widgetKey); ?>[links][<?php echo esc_attr($index); ?>][text]" value="<?php echo esc_attr($link['text'] ?? ''); ?>" required>
</div>
<div class="col-md-5 mb-2">
<label>URL</label>
<input type="text" class="form-control" name="<?php echo esc_attr($widgetKey); ?>[links][<?php echo esc_attr($index); ?>][url]" value="<?php echo esc_attr($link['url'] ?? ''); ?>" required>
</div>
<div class="col-md-2 mb-2 d-flex align-items-end">
<button type="button" class="btn btn-danger btn-sm remove-widget-link w-100">
<i class="bi bi-trash"></i> Eliminar
</button>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="button" class="btn btn-sm btn-outline-primary add-widget-link mt-2" data-widget="<?php echo esc_attr($widgetKey); ?>">
<i class="bi bi-plus-circle"></i> Añadir enlace
</button>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildNewsletterTab(): string
{
$newsletter = $this->data['newsletter'] ?? [];
$enabled = $newsletter['enabled'] ?? true;
$title = $newsletter['title'] ?? 'Suscríbete a nuestro newsletter';
$description = $newsletter['description'] ?? '';
$placeholder = $newsletter['placeholder'] ?? 'Correo electrónico';
$buttonText = $newsletter['button_text'] ?? 'Suscribirse';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="newsletter_enabled" name="newsletter[enabled]" value="1" <?php checked($enabled, true); ?>>
<label class="form-check-label" for="newsletter_enabled">
<strong>Activar sección de newsletter</strong>
</label>
</div>
</div>
<div class="form-group mb-4">
<label for="newsletter_title"><strong>Título</strong></label>
<input type="text" class="form-control" id="newsletter_title" name="newsletter[title]" value="<?php echo esc_attr($title); ?>" required>
<small class="form-text text-muted">Título de la sección de newsletter</small>
</div>
<div class="form-group mb-4">
<label for="newsletter_description"><strong>Descripción</strong></label>
<textarea class="form-control" id="newsletter_description" name="newsletter[description]" rows="3"><?php echo esc_textarea($description); ?></textarea>
<small class="form-text text-muted">Texto descriptivo debajo del título</small>
</div>
<div class="form-group mb-4">
<label for="newsletter_placeholder"><strong>Placeholder del campo</strong></label>
<input type="text" class="form-control" id="newsletter_placeholder" name="newsletter[placeholder]" value="<?php echo esc_attr($placeholder); ?>">
<small class="form-text text-muted">Texto placeholder del campo de email</small>
</div>
<div class="form-group mb-4">
<label for="newsletter_button_text"><strong>Texto del botón</strong></label>
<input type="text" class="form-control" id="newsletter_button_text" name="newsletter[button_text]" value="<?php echo esc_attr($buttonText); ?>" required>
<small class="form-text text-muted">Texto del botón de suscripción</small>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<strong>Nota:</strong> Para configurar el servicio de email de newsletter, ve a la sección de configuración general del tema.
</div>
<?php
return ob_get_clean();
}
private function buildCopyrightTab(): string
{
$copyright = $this->data['copyright'] ?? [];
$text = $copyright['text'] ?? 'ROI Theme. Todos los derechos reservados.';
$yearAuto = $copyright['year_auto'] ?? true;
ob_start();
?>
<div class="form-group mb-4">
<label for="copyright_text"><strong>Texto de copyright</strong></label>
<input type="text" class="form-control" id="copyright_text" name="copyright[text]" value="<?php echo esc_attr($text); ?>" required>
<small class="form-text text-muted">Texto que aparece en el footer</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="copyright_year_auto" name="copyright[year_auto]" value="1" <?php checked($yearAuto, true); ?>>
<label class="form-check-label" for="copyright_year_auto">
<strong>Mostrar año actual automáticamente</strong>
</label>
</div>
<small class="form-text text-muted">Si está activado, mostrará el año actual antes del texto de copyright</small>
</div>
<div class="alert alert-secondary">
<strong>Vista previa:</strong><br>
<?php if ($yearAuto): ?>
&copy; <?php echo date('Y'); ?>
<?php endif; ?>
<?php echo esc_html($text); ?>
</div>
<?php
return ob_get_clean();
}
private function buildSocialTab(): string
{
$social = $this->data['social_links'] ?? [];
$twitter = $social['twitter'] ?? '';
$instagram = $social['instagram'] ?? '';
$facebook = $social['facebook'] ?? '';
$linkedin = $social['linkedin'] ?? '';
ob_start();
?>
<p class="text-muted">
Configure los enlaces a sus redes sociales. Deje vacío para ocultar la red social correspondiente.
</p>
<div class="row">
<div class="col-md-6 mb-4">
<label for="social_twitter">
<i class="bi bi-twitter text-info"></i> <strong>Twitter</strong>
</label>
<input type="url" class="form-control" id="social_twitter" name="social_links[twitter]" value="<?php echo esc_attr($twitter); ?>" placeholder="https://twitter.com/usuario">
</div>
<div class="col-md-6 mb-4">
<label for="social_instagram">
<i class="bi bi-instagram text-danger"></i> <strong>Instagram</strong>
</label>
<input type="url" class="form-control" id="social_instagram" name="social_links[instagram]" value="<?php echo esc_attr($instagram); ?>" placeholder="https://instagram.com/usuario">
</div>
<div class="col-md-6 mb-4">
<label for="social_facebook">
<i class="bi bi-facebook text-primary"></i> <strong>Facebook</strong>
</label>
<input type="url" class="form-control" id="social_facebook" name="social_links[facebook]" value="<?php echo esc_attr($facebook); ?>" placeholder="https://facebook.com/pagina">
</div>
<div class="col-md-6 mb-4">
<label for="social_linkedin">
<i class="bi bi-linkedin text-primary"></i> <strong>LinkedIn</strong>
</label>
<input type="url" class="form-control" id="social_linkedin" name="social_links[linkedin]" value="<?php echo esc_attr($linkedin); ?>" placeholder="https://linkedin.com/company/empresa">
</div>
</div>
<?php
return ob_get_clean();
}
private function buildStylesTab(): string
{
$styles = $this->data['styles'] ?? [];
$backgroundColor = $styles['background_color'] ?? 'bg-dark';
$textColor = $styles['text_color'] ?? 'text-white';
$linkHoverColor = $styles['link_hover_color'] ?? '#FF8600';
ob_start();
?>
<div class="form-group mb-4">
<label for="background_color"><strong>Clase de fondo</strong></label>
<input type="text" class="form-control" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($backgroundColor); ?>">
<small class="form-text text-muted">Clase de Bootstrap (ej: bg-dark, bg-secondary, bg-primary)</small>
</div>
<div class="form-group mb-4">
<label for="text_color"><strong>Clase de color de texto</strong></label>
<input type="text" class="form-control" id="text_color" name="styles[text_color]" value="<?php echo esc_attr($textColor); ?>">
<small class="form-text text-muted">Clase de Bootstrap (ej: text-white, text-light, text-dark)</small>
</div>
<div class="form-group mb-4">
<label for="link_hover_color"><strong>Color de enlaces al hover</strong></label>
<input type="color" class="form-control form-control-color" id="link_hover_color" name="styles[link_hover_color]" value="<?php echo esc_attr($linkHoverColor); ?>">
<small class="form-text text-muted">Color que se mostrará cuando se pase el mouse sobre los enlaces</small>
</div>
<div class="alert alert-info">
<i class="bi bi-palette"></i>
<strong>Sugerencia:</strong> Las clases de Bootstrap disponibles incluyen: bg-primary, bg-secondary, bg-success, bg-danger, bg-warning, bg-info, bg-light, bg-dark
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace ROITheme\Footer\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class FooterRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
$componentId = $component->getId();
$backgroundClass = $data['styles']['background_color'] ?? 'bg-dark';
$textClass = $data['styles']['text_color'] ?? 'text-white';
$customStyles = $this->generateCustomStyles($data['styles'] ?? []);
ob_start();
?>
<?php if (!empty($customStyles)): ?>
<style>
<?php echo $customStyles; ?>
</style>
<?php endif; ?>
<footer id="<?php echo esc_attr($componentId); ?>" class="py-5 mt-0 <?php echo esc_attr($backgroundClass . ' ' . $textClass); ?>">
<div class="container">
<div class="row">
<?php echo $this->renderWidget($data['widget_1'] ?? [], 'col-6 col-md-2 mb-3'); ?>
<?php echo $this->renderWidget($data['widget_2'] ?? [], 'col-6 col-md-2 mb-3'); ?>
<?php echo $this->renderWidget($data['widget_3'] ?? [], 'col-6 col-md-2 mb-3'); ?>
<?php echo $this->renderNewsletter($componentId, $data['newsletter'] ?? []); ?>
</div>
<div class="d-flex flex-column flex-sm-row justify-content-between py-4 my-4 border-top">
<?php echo $this->renderCopyright($data['copyright'] ?? []); ?>
<?php echo $this->renderSocialLinks($data['social_links'] ?? []); ?>
</div>
</div>
</footer>
<?php
return ob_get_clean();
}
private function renderWidget(array $widgetData, string $colClasses): string
{
if (empty($widgetData) || !($widgetData['enabled'] ?? true)) {
return '';
}
$title = $widgetData['title'] ?? '';
$links = $widgetData['links'] ?? [];
ob_start();
?>
<div class="<?php echo esc_attr($colClasses); ?>">
<?php if (!empty($title)): ?>
<h5><?php echo esc_html($title); ?></h5>
<?php endif; ?>
<ul class="nav flex-column">
<?php foreach ($links as $link): ?>
<?php echo $this->renderLink($link); ?>
<?php endforeach; ?>
</ul>
</div>
<?php
return ob_get_clean();
}
private function renderLink(array $link): string
{
if (empty($link['text'])) {
return '';
}
$url = $link['url'] ?? '#';
$text = $link['text'] ?? '';
ob_start();
?>
<li class="nav-item mb-2">
<a href="<?php echo esc_url($url); ?>" class="nav-link p-0 text-white-50 footer-link">
<?php echo esc_html($text); ?>
</a>
</li>
<?php
return ob_get_clean();
}
private function renderNewsletter(string $componentId, array $newsletterData): string
{
if (empty($newsletterData) || !($newsletterData['enabled'] ?? true)) {
return '';
}
$title = $newsletterData['title'] ?? 'Suscríbete a nuestro newsletter';
$description = $newsletterData['description'] ?? '';
$placeholder = $newsletterData['placeholder'] ?? 'Correo electrónico';
$buttonText = $newsletterData['button_text'] ?? 'Suscribirse';
ob_start();
?>
<div class="col-md-5 offset-md-1 mb-3">
<form id="footer-newsletter-form-<?php echo esc_attr($componentId); ?>" class="footer-newsletter-form" method="post">
<?php wp_nonce_field('roi_newsletter_' . $componentId, 'newsletter_nonce'); ?>
<input type="hidden" name="action" value="roi_newsletter_subscribe">
<input type="hidden" name="component_id" value="<?php echo esc_attr($componentId); ?>">
<h5><?php echo esc_html($title); ?></h5>
<?php if (!empty($description)): ?>
<p><?php echo esc_html($description); ?></p>
<?php endif; ?>
<div class="d-flex flex-column flex-sm-row w-100 gap-2">
<label for="newsletter-email-<?php echo esc_attr($componentId); ?>" class="visually-hidden">
<?php echo esc_html($placeholder); ?>
</label>
<input
id="newsletter-email-<?php echo esc_attr($componentId); ?>"
type="email"
name="newsletter_email"
class="form-control"
placeholder="<?php echo esc_attr($placeholder); ?>"
required
>
<button class="btn btn-primary" type="submit">
<?php echo esc_html($buttonText); ?>
</button>
</div>
<div class="alert alert-success mt-3 d-none" role="alert" id="newsletter-success-<?php echo esc_attr($componentId); ?>">
¡Gracias por suscribirte! Te hemos enviado un email de confirmación.
</div>
<div class="alert alert-danger mt-3 d-none" role="alert" id="newsletter-error-<?php echo esc_attr($componentId); ?>">
Hubo un error al procesar tu suscripción. Por favor, intenta de nuevo.
</div>
</form>
<script>
(function() {
const form = document.getElementById('footer-newsletter-form-<?php echo esc_js($componentId); ?>');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!form.checkValidity()) {
e.stopPropagation();
return;
}
const formData = new FormData(form);
const submitBtn = form.querySelector('button[type="submit"]');
const successAlert = document.getElementById('newsletter-success-<?php echo esc_js($componentId); ?>');
const errorAlert = document.getElementById('newsletter-error-<?php echo esc_js($componentId); ?>');
submitBtn.disabled = true;
successAlert.classList.add('d-none');
errorAlert.classList.add('d-none');
fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
successAlert.classList.remove('d-none');
form.reset();
} else {
errorAlert.classList.remove('d-none');
}
})
.catch(error => {
errorAlert.classList.remove('d-none');
})
.finally(() => {
submitBtn.disabled = false;
});
});
})();
</script>
</div>
<?php
return ob_get_clean();
}
private function renderCopyright(array $copyrightData): string
{
if (empty($copyrightData)) {
return '';
}
$text = $copyrightData['text'] ?? 'Todos los derechos reservados.';
$yearAuto = $copyrightData['year_auto'] ?? true;
$year = $yearAuto ? date('Y') : '';
ob_start();
?>
<p class="mb-0">
<?php if (!empty($year)): ?>
&copy; <?php echo esc_html($year); ?>
<?php endif; ?>
<?php echo esc_html($text); ?>
</p>
<?php
return ob_get_clean();
}
private function renderSocialLinks(array $socialData): string
{
if (empty($socialData)) {
return '';
}
$networks = [
'twitter' => 'bi-twitter',
'instagram' => 'bi-instagram',
'facebook' => 'bi-facebook',
'linkedin' => 'bi-linkedin'
];
$hasLinks = false;
foreach ($networks as $network => $icon) {
if (!empty($socialData[$network])) {
$hasLinks = true;
break;
}
}
if (!$hasLinks) {
return '';
}
ob_start();
?>
<ul class="list-unstyled d-flex mb-0">
<?php foreach ($networks as $network => $icon): ?>
<?php if (!empty($socialData[$network])): ?>
<li class="ms-3">
<a class="link-light footer-social-link" href="<?php echo esc_url($socialData[$network]); ?>" target="_blank" rel="noopener noreferrer" aria-label="<?php echo esc_attr(ucfirst($network)); ?>">
<i class="bi <?php echo esc_attr($icon); ?>" style="font-size: 1.25rem;"></i>
</a>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php
return ob_get_clean();
}
private function generateCustomStyles(array $styles): string
{
if (empty($styles)) {
return '';
}
$css = [];
if (isset($styles['link_hover_color'])) {
$css[] = "footer .footer-link:hover { color: {$styles['link_hover_color']} !important; }";
$css[] = "footer .footer-social-link:hover { color: {$styles['link_hover_color']} !important; }";
}
return implode("\n", $css);
}
public function supports(string $componentType): bool
{
return $componentType === 'footer';
}
}

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace ROITheme\HeroSection\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class HeroSectionFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
$data = $this->data;
$componentId = $this->componentId;
$html = '<div class="roi-form-builder roi-hero-section-form">';
$html .= $this->buildTabsNavigation();
$html .= '<div class="tab-content mt-3">';
$html .= $this->buildVisibilityTab($data, $componentId);
$html .= $this->buildCategoriesTab($data, $componentId);
$html .= $this->buildTitleTab($data, $componentId);
$html .= $this->buildStylesTab($data, $componentId);
$html .= '</div></div>';
$html .= $this->buildFormScripts($componentId);
return $html;
}
private function buildTabsNavigation(): string
{
return <<<HTML
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#categories-tab" type="button">Categorías</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#title-tab" type="button">Título</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button></li>
</ul>
HTML;
}
private function buildVisibilityTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade show active" id="visibility-tab"><div class="p-3">';
$isEnabled = $data['visibility']['is_enabled'] ?? true;
$html .= $this->buildToggle('is_enabled', 'Mostrar hero section', $isEnabled, $componentId, 'visibility');
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
$html .= $this->buildSelect('show_on_pages', 'Mostrar en', $showOn, ['all' => 'Todas las páginas', 'home' => 'Solo página de inicio', 'posts' => 'Solo posts individuales', 'pages' => 'Solo páginas', 'custom' => 'Tipos de post específicos'], $componentId, 'visibility');
$customPostTypes = $data['visibility']['custom_post_types'] ?? '';
$html .= $this->buildTextField('custom_post_types', 'Tipos de post personalizados', $customPostTypes, $componentId, 'visibility', 'Ej: post,page,producto', ['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']);
$html .= '</div></div>';
return $html;
}
private function buildCategoriesTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="categories-tab"><div class="p-3">';
$showCategories = $data['categories']['show_categories'] ?? true;
$html .= $this->buildToggle('show_categories', 'Mostrar badges de categorías', $showCategories, $componentId, 'categories');
$categoriesSource = $data['categories']['categories_source'] ?? 'post_categories';
$html .= $this->buildSelect('categories_source', 'Fuente de categorías', $categoriesSource, ['post_categories' => 'Categorías del post', 'post_tags' => 'Etiquetas del post', 'custom_taxonomy' => 'Taxonomía personalizada', 'custom_list' => 'Lista personalizada'], $componentId, 'categories', ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
$customTaxonomy = $data['categories']['custom_taxonomy_name'] ?? '';
$html .= $this->buildTextField('custom_taxonomy_name', 'Nombre de taxonomía personalizada', $customTaxonomy, $componentId, 'categories', 'Ej: project_category', ['data-conditional-field' => 'categories_source', 'data-conditional-value' => 'custom_taxonomy']);
$customList = $data['categories']['custom_categories_list'] ?? '';
$html .= $this->buildTextArea('custom_categories_list', 'Lista personalizada de categorías', $customList, $componentId, 'categories', 'Análisis de Precios|#', 5, ['data-conditional-field' => 'categories_source', 'data-conditional-value' => 'custom_list']);
$maxCategories = $data['categories']['max_categories'] ?? 5;
$html .= $this->buildNumberField('max_categories', 'Máximo de categorías a mostrar', $maxCategories, $componentId, 'categories', 1, 20, ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
$categoryIcon = $data['categories']['category_icon'] ?? 'bi-folder-fill';
$html .= $this->buildTextField('category_icon', 'Ícono de categoría', $categoryIcon, $componentId, 'categories', 'Ej: bi-folder-fill', ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
$categoriesAlignment = $data['categories']['categories_alignment'] ?? 'center';
$html .= $this->buildSelect('categories_alignment', 'Alineación de categorías', $categoriesAlignment, ['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'], $componentId, 'categories', ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
$html .= '</div></div>';
return $html;
}
private function buildTitleTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="title-tab"><div class="p-3">';
$titleSource = $data['title']['title_source'] ?? 'post_title';
$html .= $this->buildSelect('title_source', 'Fuente del título', $titleSource, ['post_title' => 'Título del post', 'custom_field' => 'Campo personalizado', 'custom_text' => 'Texto personalizado'], $componentId, 'title');
$customField = $data['title']['custom_field_name'] ?? '';
$html .= $this->buildTextField('custom_field_name', 'Nombre del campo personalizado', $customField, $componentId, 'title', 'Ej: hero_title', ['data-conditional-field' => 'title_source', 'data-conditional-value' => 'custom_field']);
$customText = $data['title']['custom_text'] ?? '';
$html .= $this->buildTextArea('custom_text', 'Texto personalizado', $customText, $componentId, 'title', '', 3, ['data-conditional-field' => 'title_source', 'data-conditional-value' => 'custom_text']);
$titleTag = $data['title']['title_tag'] ?? 'h1';
$html .= $this->buildSelect('title_tag', 'Etiqueta HTML del título', $titleTag, ['h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'div' => 'DIV'], $componentId, 'title');
$titleClasses = $data['title']['title_classes'] ?? 'display-5 fw-bold';
$html .= $this->buildTextField('title_classes', 'Clases CSS adicionales', $titleClasses, $componentId, 'title', 'Ej: display-5 fw-bold');
$titleAlignment = $data['title']['title_alignment'] ?? 'center';
$html .= $this->buildSelect('title_alignment', 'Alineación del título', $titleAlignment, ['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'], $componentId, 'title');
$enableGradient = $data['title']['enable_gradient'] ?? false;
$html .= $this->buildToggle('enable_gradient', 'Activar gradiente en el texto', $enableGradient, $componentId, 'title');
$gradientStart = $data['title']['gradient_color_start'] ?? '#1e3a5f';
$html .= $this->buildColorField('gradient_color_start', 'Color inicial del gradiente', $gradientStart, $componentId, 'title', ['data-conditional-field' => 'enable_gradient', 'data-conditional-value' => 'true']);
$gradientEnd = $data['title']['gradient_color_end'] ?? '#FF8600';
$html .= $this->buildColorField('gradient_color_end', 'Color final del gradiente', $gradientEnd, $componentId, 'title', ['data-conditional-field' => 'enable_gradient', 'data-conditional-value' => 'true']);
$gradientDirection = $data['title']['gradient_direction'] ?? 'to-right';
$html .= $this->buildSelect('gradient_direction', 'Dirección del gradiente', $gradientDirection, ['to-right' => 'Izquierda a derecha', 'to-left' => 'Derecha a izquierda', 'to-bottom' => 'Arriba a abajo', 'to-top' => 'Abajo a arriba', 'diagonal' => 'Diagonal'], $componentId, 'title', ['data-conditional-field' => 'enable_gradient', 'data-conditional-value' => 'true']);
$html .= '</div></div>';
return $html;
}
private function buildStylesTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="styles-tab"><div class="p-3">';
$backgroundType = $data['styles']['background_type'] ?? 'gradient';
$html .= $this->buildSelect('background_type', 'Tipo de fondo', $backgroundType, ['color' => 'Color sólido', 'gradient' => 'Gradiente', 'image' => 'Imagen', 'none' => 'Sin fondo'], $componentId, 'styles');
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
$html .= $this->buildColorField('background_color', 'Color de fondo', $bgColor, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'color']);
$gradientStart = $data['styles']['gradient_start_color'] ?? '#1e3a5f';
$html .= $this->buildColorField('gradient_start_color', 'Color inicial del gradiente', $gradientStart, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'gradient']);
$gradientEnd = $data['styles']['gradient_end_color'] ?? '#2c5282';
$html .= $this->buildColorField('gradient_end_color', 'Color final del gradiente', $gradientEnd, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'gradient']);
$gradientAngle = $data['styles']['gradient_angle'] ?? 135;
$html .= $this->buildNumberField('gradient_angle', 'Ángulo del gradiente (grados)', $gradientAngle, $componentId, 'styles', 0, 360, ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'gradient']);
$bgImage = $data['styles']['background_image_url'] ?? '';
$html .= $this->buildMediaField('background_image_url', 'Imagen de fondo', $bgImage, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'image']);
$bgOverlay = $data['styles']['background_overlay'] ?? true;
$html .= $this->buildToggle('background_overlay', 'Overlay oscuro sobre imagen', $bgOverlay, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'image']);
$overlayOpacity = $data['styles']['overlay_opacity'] ?? 60;
$html .= $this->buildNumberField('overlay_opacity', 'Opacidad del overlay (%)', $overlayOpacity, $componentId, 'styles', 0, 100, ['data-conditional-field' => 'background_overlay', 'data-conditional-value' => 'true']);
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField('text_color', 'Color del texto', $textColor, $componentId, 'styles');
$padding = $data['styles']['padding_vertical'] ?? 'normal';
$html .= $this->buildSelect('padding_vertical', 'Padding vertical', $padding, ['compact' => 'Compacto (2rem)', 'normal' => 'Normal (3rem)', 'spacious' => 'Espacioso (4rem)', 'extra-spacious' => 'Extra espacioso (5rem)'], $componentId, 'styles');
$margin = $data['styles']['margin_bottom'] ?? 'normal';
$html .= $this->buildSelect('margin_bottom', 'Margen inferior', $margin, ['none' => 'Sin margen', 'small' => 'Pequeño (1rem)', 'normal' => 'Normal (1.5rem)', 'large' => 'Grande (2rem)'], $componentId, 'styles');
$badgeBg = $data['styles']['category_badge_background'] ?? 'rgba(255, 255, 255, 0.2)';
$html .= $this->buildTextField('category_badge_background', 'Fondo de badges', $badgeBg, $componentId, 'styles');
$badgeText = $data['styles']['category_badge_text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField('category_badge_text_color', 'Color del texto de badges', $badgeText, $componentId, 'styles');
$badgeBlur = $data['styles']['category_badge_blur'] ?? true;
$html .= $this->buildToggle('category_badge_blur', 'Efecto blur en badges', $badgeBlur, $componentId, 'styles');
$html .= '</div></div>';
return $html;
}
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$checked = $value ? 'checked' : '';
$attrString = $this->buildAttributesString($attrs);
return sprintf('<div class="roi-form-field mb-3"><div class="form-check form-switch"><input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s][%s]" value="1" %s%s><label class="form-check-label" for="%s">%s</label></div></div>', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $checked, $attrString, esc_attr($fieldId), esc_html($label));
}
private function buildTextField(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" placeholder="%s"%s></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), esc_attr($placeholder), $attrString);
}
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', int $rows = 3, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><textarea class="form-control" id="%s" name="roi_component[%s][%s][%s]" rows="%d" placeholder="%s"%s>%s</textarea></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $rows, esc_attr($placeholder), $attrString, esc_textarea($value));
}
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
$html = sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= sprintf('<select class="form-select" id="%s" name="roi_component[%s][%s][%s]"%s>', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $attrString);
foreach ($options as $optValue => $optLabel) {
$selected = ($value === $optValue) ? 'selected' : '';
$html .= sprintf('<option value="%s" %s>%s</option>', esc_attr($optValue), $selected, esc_html($optLabel));
}
$html .= '</select></div>';
return $html;
}
private function buildNumberField(string $name, string $label, $value, string $componentId, string $group, int $min = null, int $max = null, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrs['type'] = 'number';
if ($min !== null) $attrs['min'] = $min;
if ($max !== null) $attrs['max'] = $max;
$attrString = $this->buildAttributesString($attrs);
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><input class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s"%s></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), $attrString);
}
private function buildColorField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><div class="input-group"><input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s][%s]" value="%s"><input type="text" class="form-control" value="%s" readonly></div></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), esc_attr($value));
}
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
$html = sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$html .= sprintf('<input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" readonly%s>', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), $attrString);
$html .= sprintf('<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>', esc_attr($fieldId));
$html .= '</div>';
if (!empty($value)) {
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 300px; height: auto;"></div>', esc_url($value));
}
$html .= '</div>';
return $html;
}
private function buildAttributesString(array $attrs): string
{
$attrString = '';
foreach ($attrs as $key => $value) {
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
}
return $attrString;
}
private function buildFormScripts(string $componentId): string
{
return <<<SCRIPT
<script>
(function($) {
'use strict';
$(document).ready(function() {
$('[data-conditional-field]').each(function() {
const field = $(this);
const targetFieldName = field.data('conditional-field');
const targetValue = field.data('conditional-value');
const targetField = $('[name*="[' + targetFieldName + ']"]');
function updateVisibility() {
let currentValue = targetField.is(':checkbox') ? (targetField.is(':checked') ? 'true' : 'false') : targetField.val();
field.closest('.roi-form-field')[currentValue === targetValue ? 'show' : 'hide']();
}
targetField.on('change', updateVisibility);
updateVisibility();
});
$('.roi-media-upload-btn').on('click', function(e) {
e.preventDefault();
const button = $(this);
const targetId = button.data('target');
const targetField = $('#' + targetId);
const mediaUploader = wp.media({title: 'Seleccionar imagen', button: {text: 'Usar esta imagen'}, multiple: false});
mediaUploader.on('select', function() {
const attachment = mediaUploader.state().get('selection').first().toJSON();
targetField.val(attachment.url);
const preview = targetField.closest('.roi-form-field').find('img');
if (preview.length) {
preview.attr('src', attachment.url);
} else {
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 300px; height: auto;"></div>');
}
});
mediaUploader.open();
});
$('.form-control-color').on('change', function() {
$(this).next('input[type="text"]').val($(this).val());
});
});
})(jQuery);
</script>
SCRIPT;
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,465 @@
<?php
declare(strict_types=1);
namespace ROITheme\HeroSection\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
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,665 @@
<?php
declare(strict_types=1);
namespace ROITheme\Navbar\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class NavbarFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
$data = $this->data;
$componentId = $this->componentId;
$html = '<div class="roi-form-builder roi-navbar-form">';
// Tabs navigation
$html .= $this->buildTabsNavigation();
// Tab content
$html .= '<div class="tab-content mt-3">';
// Visibility tab
$html .= $this->buildVisibilityTab($data, $componentId);
// Logo tab
$html .= $this->buildLogoTab($data, $componentId);
// Menu tab
$html .= $this->buildMenuTab($data, $componentId);
// CTA Button tab
$html .= $this->buildCtaButtonTab($data, $componentId);
// Styles tab
$html .= $this->buildStylesTab($data, $componentId);
$html .= '</div>';
$html .= '</div>';
$html .= $this->buildFormScripts($componentId);
return $html;
}
private function buildTabsNavigation(): string
{
return <<<HTML
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logo-tab" type="button">Logo</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#menu-tab" type="button">Menú</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#cta-tab" type="button">Botón CTA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
</ul>
HTML;
}
private function buildVisibilityTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade show active" id="visibility-tab">';
$html .= '<div class="p-3">';
$isEnabled = $data['visibility']['is_enabled'] ?? true;
$html .= $this->buildToggle('is_enabled', 'Mostrar navbar', $isEnabled, $componentId, 'visibility');
$isSticky = $data['visibility']['is_sticky'] ?? true;
$html .= $this->buildToggle('is_sticky', 'Navbar fijo (sticky)', $isSticky, $componentId, 'visibility');
$hideOnScroll = $data['visibility']['hide_on_scroll'] ?? false;
$html .= $this->buildToggle('hide_on_scroll', 'Ocultar al hacer scroll hacia abajo', $hideOnScroll, $componentId, 'visibility');
$showOnMobile = $data['visibility']['show_on_mobile'] ?? true;
$html .= $this->buildToggle('show_on_mobile', 'Mostrar en dispositivos móviles', $showOnMobile, $componentId, 'visibility');
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildLogoTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="logo-tab">';
$html .= '<div class="p-3">';
$logoType = $data['logo']['logo_type'] ?? 'image';
$html .= $this->buildSelect(
'logo_type',
'Tipo de logo',
$logoType,
['image' => 'Imagen', 'text' => 'Texto', 'none' => 'Sin logo'],
$componentId,
'logo'
);
$logoImageUrl = $data['logo']['logo_image_url'] ?? '';
$html .= $this->buildMediaField(
'logo_image_url',
'Imagen del logo',
$logoImageUrl,
$componentId,
'logo',
['data-conditional-field' => 'logo_type', 'data-conditional-value' => 'image']
);
$logoWidth = $data['logo']['logo_image_width'] ?? 150;
$html .= $this->buildNumberField(
'logo_image_width',
'Ancho del logo (px)',
$logoWidth,
$componentId,
'logo',
50,
400,
['data-conditional-field' => 'logo_type', 'data-conditional-value' => 'image']
);
$logoText = $data['logo']['logo_text'] ?? '';
$html .= $this->buildTextField(
'logo_text',
'Texto del logo',
$logoText,
$componentId,
'logo',
'',
['data-conditional-field' => 'logo_type', 'data-conditional-value' => 'text']
);
$logoLink = $data['logo']['logo_link'] ?? '';
$html .= $this->buildUrlField(
'logo_link',
'Enlace del logo',
$logoLink,
$componentId,
'logo',
'Dejar vacío para usar la URL del home'
);
$logoPosition = $data['logo']['logo_position'] ?? 'left';
$html .= $this->buildSelect(
'logo_position',
'Posición del logo',
$logoPosition,
['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'],
$componentId,
'logo'
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildMenuTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="menu-tab">';
$html .= '<div class="p-3">';
$menuLocation = $data['menu']['menu_location'] ?? 'primary';
$html .= $this->buildSelect(
'menu_location',
'Ubicación del menú',
$menuLocation,
['primary' => 'Menú Principal', 'secondary' => 'Menú Secundario', 'custom' => 'Menú personalizado'],
$componentId,
'menu'
);
$customMenuId = $data['menu']['custom_menu_id'] ?? 0;
$html .= $this->buildNumberField(
'custom_menu_id',
'ID del menú personalizado',
$customMenuId,
$componentId,
'menu',
0,
99999,
['data-conditional-field' => 'menu_location', 'data-conditional-value' => 'custom']
);
$menuAlignment = $data['menu']['menu_alignment'] ?? 'left';
$html .= $this->buildSelect(
'menu_alignment',
'Alineación del menú',
$menuAlignment,
['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'],
$componentId,
'menu'
);
$enableDropdowns = $data['menu']['enable_dropdowns'] ?? true;
$html .= $this->buildToggle('enable_dropdowns', 'Habilitar menús desplegables', $enableDropdowns, $componentId, 'menu');
$dropdownAnimation = $data['menu']['dropdown_animation'] ?? 'fade';
$html .= $this->buildSelect(
'dropdown_animation',
'Animación de dropdowns',
$dropdownAnimation,
['none' => 'Sin animación', 'fade' => 'Aparecer gradualmente', 'slide' => 'Deslizar'],
$componentId,
'menu',
['data-conditional-field' => 'enable_dropdowns', 'data-conditional-value' => 'true']
);
$mobileBreakpoint = $data['menu']['mobile_breakpoint'] ?? 'lg';
$html .= $this->buildSelect(
'mobile_breakpoint',
'Breakpoint para menú móvil',
$mobileBreakpoint,
['sm' => 'Small (576px)', 'md' => 'Medium (768px)', 'lg' => 'Large (992px)', 'xl' => 'Extra Large (1200px)'],
$componentId,
'menu'
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildCtaButtonTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="cta-tab">';
$html .= '<div class="p-3">';
$buttonEnabled = $data['cta_button']['button_enabled'] ?? true;
$html .= $this->buildToggle('button_enabled', 'Mostrar botón CTA', $buttonEnabled, $componentId, 'cta_button');
$buttonText = $data['cta_button']['button_text'] ?? 'Let\'s Talk';
$html .= $this->buildTextField(
'button_text',
'Texto del botón',
$buttonText,
$componentId,
'cta_button',
'',
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
);
$buttonIcon = $data['cta_button']['button_icon'] ?? 'bi-lightning-charge-fill';
$html .= $this->buildTextField(
'button_icon',
'Ícono del botón',
$buttonIcon,
$componentId,
'cta_button',
'Ej: bi-lightning-charge-fill',
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
);
$actionType = $data['cta_button']['button_action_type'] ?? 'modal';
$html .= $this->buildSelect(
'button_action_type',
'Tipo de acción del botón',
$actionType,
['modal' => 'Abrir modal', 'link' => 'Ir a URL', 'scroll' => 'Scroll a sección'],
$componentId,
'cta_button',
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
);
$modalTarget = $data['cta_button']['button_modal_target'] ?? '#contactModal';
$html .= $this->buildTextField(
'button_modal_target',
'ID del modal',
$modalTarget,
$componentId,
'cta_button',
'#contactModal',
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'modal']
);
$linkUrl = $data['cta_button']['button_link_url'] ?? '';
$html .= $this->buildUrlField(
'button_link_url',
'URL del enlace',
$linkUrl,
$componentId,
'cta_button',
'https://',
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'link']
);
$linkTarget = $data['cta_button']['button_link_target'] ?? '_self';
$html .= $this->buildSelect(
'button_link_target',
'Abrir enlace en',
$linkTarget,
['_self' => 'Misma ventana', '_blank' => 'Nueva ventana'],
$componentId,
'cta_button',
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'link']
);
$scrollTarget = $data['cta_button']['button_scroll_target'] ?? '';
$html .= $this->buildTextField(
'button_scroll_target',
'ID de la sección',
$scrollTarget,
$componentId,
'cta_button',
'#contact',
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'scroll']
);
$buttonPosition = $data['cta_button']['button_position'] ?? 'right';
$html .= $this->buildSelect(
'button_position',
'Posición del botón',
$buttonPosition,
['left' => 'Antes del menú', 'right' => 'Después del menú'],
$componentId,
'cta_button',
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildStylesTab(array $data, string $componentId): string
{
$html = '<div class="tab-pane fade" id="styles-tab">';
$html .= '<div class="p-3">';
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
$html .= $this->buildColorField('background_color', 'Color de fondo', $bgColor, $componentId, 'styles');
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField('text_color', 'Color del texto', $textColor, $componentId, 'styles');
$hoverColor = $data['styles']['hover_color'] ?? '#FF8600';
$html .= $this->buildColorField('hover_color', 'Color hover', $hoverColor, $componentId, 'styles');
$activeColor = $data['styles']['active_color'] ?? '#FF8600';
$html .= $this->buildColorField('active_color', 'Color del item activo', $activeColor, $componentId, 'styles');
$buttonBg = $data['styles']['button_background'] ?? '#FF8600';
$html .= $this->buildColorField('button_background', 'Color de fondo del botón', $buttonBg, $componentId, 'styles');
$buttonTextColor = $data['styles']['button_text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField('button_text_color', 'Color del texto del botón', $buttonTextColor, $componentId, 'styles');
$buttonHoverBg = $data['styles']['button_hover_background'] ?? '#FF6B35';
$html .= $this->buildColorField('button_hover_background', 'Color hover del botón', $buttonHoverBg, $componentId, 'styles');
$padding = $data['styles']['padding_vertical'] ?? 'normal';
$html .= $this->buildSelect(
'padding_vertical',
'Padding vertical',
$padding,
['compact' => 'Compacto (0.5rem)', 'normal' => 'Normal (1rem)', 'spacious' => 'Espacioso (1.5rem)'],
$componentId,
'styles'
);
$shadowEnabled = $data['styles']['shadow_enabled'] ?? true;
$html .= $this->buildToggle('shadow_enabled', 'Activar sombra', $shadowEnabled, $componentId, 'styles');
$shadowIntensity = $data['styles']['shadow_intensity'] ?? 'medium';
$html .= $this->buildSelect(
'shadow_intensity',
'Intensidad de la sombra',
$shadowIntensity,
['light' => 'Ligera', 'medium' => 'Media', 'strong' => 'Fuerte'],
$componentId,
'styles',
['data-conditional-field' => 'shadow_enabled', 'data-conditional-value' => 'true']
);
$borderEnabled = $data['styles']['border_bottom_enabled'] ?? false;
$html .= $this->buildToggle('border_bottom_enabled', 'Borde inferior', $borderEnabled, $componentId, 'styles');
$borderColor = $data['styles']['border_bottom_color'] ?? '#FF8600';
$html .= $this->buildColorField(
'border_bottom_color',
'Color del borde inferior',
$borderColor,
$componentId,
'styles',
['data-conditional-field' => 'border_bottom_enabled', 'data-conditional-value' => 'true']
);
$borderWidth = $data['styles']['border_bottom_width'] ?? 3;
$html .= $this->buildNumberField(
'border_bottom_width',
'Grosor del borde (px)',
$borderWidth,
$componentId,
'styles',
1,
10,
['data-conditional-field' => 'border_bottom_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
// Helper methods (similar to TopNotificationBarFormBuilder)
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $group): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$checked = $value ? 'checked' : '';
$html = '<div class="roi-form-field mb-3">';
$html .= '<div class="form-check form-switch">';
$html .= sprintf(
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s][%s]" value="1" %s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($group),
esc_attr($name),
$checked
);
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildTextField(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
$html = '<div class="roi-form-field mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" placeholder="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($group),
esc_attr($name),
esc_attr($value),
esc_attr($placeholder),
$attrString
);
$html .= '</div>';
return $html;
}
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
$html = '<div class="roi-form-field mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= sprintf(
'<select class="form-select" id="%s" name="roi_component[%s][%s][%s]"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($group),
esc_attr($name),
$attrString
);
foreach ($options as $optValue => $optLabel) {
$selected = ($value === $optValue) ? 'selected' : '';
$html .= sprintf('<option value="%s" %s>%s</option>', esc_attr($optValue), $selected, esc_html($optLabel));
}
$html .= '</select>';
$html .= '</div>';
return $html;
}
private function buildNumberField(string $name, string $label, $value, string $componentId, string $group, int $min = null, int $max = null, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrs['type'] = 'number';
if ($min !== null) $attrs['min'] = $min;
if ($max !== null) $attrs['max'] = $max;
$attrString = $this->buildAttributesString($attrs);
$html = '<div class="roi-form-field mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= sprintf(
'<input class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($group),
esc_attr($name),
esc_attr($value),
$attrString
);
$html .= '</div>';
return $html;
}
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', array $attrs = []): string
{
$attrs['type'] = 'url';
return $this->buildTextField($name, $label, $value, $componentId, $group, $placeholder, $attrs);
}
private function buildColorField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$html = '<div class="roi-form-field mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$html .= sprintf(
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s][%s]" value="%s">',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($group),
esc_attr($name),
esc_attr($value)
);
$html .= sprintf('<input type="text" class="form-control" value="%s" readonly>', esc_attr($value));
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$group}_{$name}";
$attrString = $this->buildAttributesString($attrs);
$html = '<div class="roi-form-field mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" readonly%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($group),
esc_attr($name),
esc_attr($value),
$attrString
);
$html .= sprintf('<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>', esc_attr($fieldId));
$html .= '</div>';
if (!empty($value)) {
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 200px; height: auto;"></div>', esc_url($value));
}
$html .= '</div>';
return $html;
}
private function buildAttributesString(array $attrs): string
{
$attrString = '';
foreach ($attrs as $key => $value) {
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
}
return $attrString;
}
private function buildFormScripts(string $componentId): string
{
return <<<SCRIPT
<script>
(function($) {
'use strict';
$(document).ready(function() {
// Conditional logic handler
$('[data-conditional-field]').each(function() {
const field = $(this);
const targetFieldName = field.data('conditional-field');
const targetValue = field.data('conditional-value');
const targetField = $('[name*="[' + targetFieldName + ']"]');
function updateVisibility() {
let currentValue;
if (targetField.is(':checkbox')) {
currentValue = targetField.is(':checked') ? 'true' : 'false';
} else {
currentValue = targetField.val();
}
if (currentValue === targetValue) {
field.closest('.roi-form-field').show();
} else {
field.closest('.roi-form-field').hide();
}
}
targetField.on('change', updateVisibility);
updateVisibility();
});
// Media uploader
$('.roi-media-upload-btn').on('click', function(e) {
e.preventDefault();
const button = $(this);
const targetId = button.data('target');
const targetField = $('#' + targetId);
const mediaUploader = wp.media({
title: 'Seleccionar imagen',
button: { text: 'Usar esta imagen' },
multiple: false
});
mediaUploader.on('select', function() {
const attachment = mediaUploader.state().get('selection').first().toJSON();
targetField.val(attachment.url);
const preview = targetField.closest('.roi-form-field').find('img');
if (preview.length) {
preview.attr('src', attachment.url);
} else {
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 200px; height: auto;"></div>');
}
});
mediaUploader.open();
});
// Color picker sync
$('.form-control-color').on('change', function() {
$(this).next('input[type="text"]').val($(this).val());
});
});
})(jQuery);
</script>
SCRIPT;
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,507 @@
<?php
declare(strict_types=1);
namespace ROITheme\Navbar\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
use Walker_Nav_Menu;
final class NavbarRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$classes = $this->buildNavbarClasses($data);
$styles = $this->buildInlineStyles($data);
$containerClasses = $this->buildContainerClasses($data);
$html = sprintf(
'<nav class="%s"%s>',
esc_attr($classes),
$styles ? ' style="' . esc_attr($styles) . '"' : ''
);
$html .= sprintf('<div class="%s">', esc_attr($containerClasses));
// Logo
if ($this->shouldShowLogo($data)) {
$html .= $this->buildLogo($data);
}
// Mobile toggle button
if ($this->shouldShowOnMobile($data)) {
$html .= $this->buildMobileToggle($data);
}
// Navbar collapse
$html .= '<div class="collapse navbar-collapse" id="navbarSupportedContent">';
// Button left position
if ($this->isButtonPosition($data, 'left')) {
$html .= $this->buildCtaButton($data);
}
// Menu
$html .= $this->buildMenu($data);
// Button right position
if ($this->isButtonPosition($data, 'right')) {
$html .= $this->buildCtaButton($data);
}
$html .= '</div>';
$html .= '</div>';
$html .= '</nav>';
// Add custom styles
$html .= $this->buildCustomStyles($data);
// Add sticky/hide on scroll scripts
if ($this->needsScrollScript($data)) {
$html .= $this->buildScrollScript($data);
}
return $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;
}
private function shouldShowLogo(array $data): bool
{
$logoType = $data['logo']['logo_type'] ?? 'none';
return $logoType !== 'none';
}
private function isButtonPosition(array $data, string $position): bool
{
if (!isset($data['cta_button']['button_enabled']) || !$data['cta_button']['button_enabled']) {
return false;
}
$buttonPosition = $data['cta_button']['button_position'] ?? 'right';
return $buttonPosition === $position;
}
private function needsScrollScript(array $data): bool
{
return (isset($data['visibility']['is_sticky']) && $data['visibility']['is_sticky']) ||
(isset($data['visibility']['hide_on_scroll']) && $data['visibility']['hide_on_scroll']);
}
private function buildNavbarClasses(array $data): string
{
$classes = ['navbar', 'navbar-expand-' . ($data['menu']['mobile_breakpoint'] ?? 'lg'), 'navbar-dark'];
if (isset($data['visibility']['is_sticky']) && $data['visibility']['is_sticky']) {
$classes[] = 'sticky-top';
}
if (isset($data['visibility']['hide_on_scroll']) && $data['visibility']['hide_on_scroll']) {
$classes[] = 'roi-navbar-autohide';
}
$paddingClass = $this->getPaddingClass($data['styles']['padding_vertical'] ?? 'normal');
$classes[] = $paddingClass;
return implode(' ', $classes);
}
private function getPaddingClass(string $padding): string
{
$paddings = [
'compact' => 'py-2',
'normal' => 'py-3',
'spacious' => 'py-4'
];
return $paddings[$padding] ?? 'py-3';
}
private function buildContainerClasses(array $data): string
{
return 'container';
}
private function buildInlineStyles(array $data): string
{
$styles = [];
if (!empty($data['styles']['background_color'])) {
$styles[] = 'background-color: ' . $data['styles']['background_color'];
}
if (isset($data['styles']['border_bottom_enabled']) && $data['styles']['border_bottom_enabled']) {
$borderColor = $data['styles']['border_bottom_color'] ?? '#FF8600';
$borderWidth = $data['styles']['border_bottom_width'] ?? 3;
$styles[] = "border-bottom: {$borderWidth}px solid {$borderColor}";
}
return implode('; ', $styles);
}
private function buildLogo(array $data): string
{
$logoType = $data['logo']['logo_type'] ?? 'image';
$logoLink = $data['logo']['logo_link'] ?? home_url('/');
$logoPosition = $data['logo']['logo_position'] ?? 'left';
$logoHtml = '<a class="navbar-brand" href="' . esc_url($logoLink) . '">';
switch ($logoType) {
case 'image':
$imageUrl = $data['logo']['logo_image_url'] ?? '';
$imageWidth = $data['logo']['logo_image_width'] ?? 150;
if (!empty($imageUrl)) {
$logoHtml .= sprintf(
'<img src="%s" alt="%s" style="width: %dpx; height: auto;">',
esc_url($imageUrl),
esc_attr(get_bloginfo('name')),
(int)$imageWidth
);
}
break;
case 'text':
$logoText = $data['logo']['logo_text'] ?? get_bloginfo('name');
$logoHtml .= esc_html($logoText);
break;
}
$logoHtml .= '</a>';
if ($logoPosition === 'center') {
$logoHtml = '<div class="mx-auto">' . $logoHtml . '</div>';
}
return $logoHtml;
}
private function buildMobileToggle(array $data): string
{
return '<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>';
}
private function buildMenu(array $data): string
{
$menuLocation = $data['menu']['menu_location'] ?? 'primary';
$menuAlignment = $data['menu']['menu_alignment'] ?? 'left';
$enableDropdowns = $data['menu']['enable_dropdowns'] ?? true;
$alignmentClasses = [
'left' => 'me-auto',
'center' => 'mx-auto',
'right' => 'ms-auto'
];
$ulClass = 'navbar-nav mb-2 mb-lg-0 ' . ($alignmentClasses[$menuAlignment] ?? 'me-auto');
$args = [
'theme_location' => $menuLocation === 'custom' ? '' : $menuLocation,
'menu' => $menuLocation === 'custom' ? ($data['menu']['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();
}
private function buildCtaButton(array $data): string
{
if (!isset($data['cta_button']['button_enabled']) || !$data['cta_button']['button_enabled']) {
return '';
}
$buttonText = $data['cta_button']['button_text'] ?? 'Let\'s Talk';
$buttonIcon = $data['cta_button']['button_icon'] ?? 'bi-lightning-charge-fill';
$actionType = $data['cta_button']['button_action_type'] ?? 'modal';
$buttonClass = 'btn btn-lets-talk ms-lg-3';
$buttonAttrs = [];
switch ($actionType) {
case 'modal':
$modalTarget = $data['cta_button']['button_modal_target'] ?? '#contactModal';
$buttonAttrs[] = 'data-bs-toggle="modal"';
$buttonAttrs[] = 'data-bs-target="' . esc_attr($modalTarget) . '"';
$element = 'button';
$href = '';
break;
case 'link':
$linkUrl = $data['cta_button']['button_link_url'] ?? '#';
$linkTarget = $data['cta_button']['button_link_target'] ?? '_self';
$element = 'a';
$href = ' href="' . esc_url($linkUrl) . '" target="' . esc_attr($linkTarget) . '"';
break;
case 'scroll':
$scrollTarget = $data['cta_button']['button_scroll_target'] ?? '#contact';
$element = 'a';
$href = ' href="' . esc_attr($scrollTarget) . '"';
$buttonAttrs[] = 'data-scroll="true"';
break;
default:
$element = 'button';
$href = '';
}
$attrString = !empty($buttonAttrs) ? ' ' . implode(' ', $buttonAttrs) : '';
$iconHtml = '';
if (!empty($buttonIcon)) {
if (strpos($buttonIcon, 'bi-') !== 0) {
$buttonIcon = 'bi-' . $buttonIcon;
}
$iconHtml = sprintf('<i class="bi %s me-2"></i>', esc_attr($buttonIcon));
}
return sprintf(
'<%s class="%s"%s%s>%s%s</%s>',
$element,
esc_attr($buttonClass),
$href,
$attrString,
$iconHtml,
esc_html($buttonText),
$element
);
}
private function buildCustomStyles(array $data): string
{
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
$hoverColor = $data['styles']['hover_color'] ?? '#FF8600';
$activeColor = $data['styles']['active_color'] ?? '#FF8600';
$buttonBg = $data['styles']['button_background'] ?? '#FF8600';
$buttonTextColor = $data['styles']['button_text_color'] ?? '#FFFFFF';
$buttonHoverBg = $data['styles']['button_hover_background'] ?? '#FF6B35';
$backgroundColor = $data['styles']['background_color'] ?? '#1e3a5f';
$shadowStyle = '';
if (isset($data['styles']['shadow_enabled']) && $data['styles']['shadow_enabled']) {
$shadowIntensity = $data['styles']['shadow_intensity'] ?? 'medium';
$shadows = [
'light' => '0 2px 4px rgba(0,0,0,0.1)',
'medium' => '0 2px 8px rgba(0,0,0,0.15)',
'strong' => '0 4px 12px rgba(0,0,0,0.25)'
];
$shadowStyle = 'box-shadow: ' . $shadows[$shadowIntensity] . ';';
}
return <<<STYLES
<style>
.navbar {
{$shadowStyle}
}
.navbar .nav-link {
color: {$textColor} !important;
transition: color 0.3s ease;
}
.navbar .nav-link:hover,
.navbar .nav-link:focus {
color: {$hoverColor} !important;
}
.navbar .nav-link.active,
.navbar .nav-item.current-menu-item > .nav-link {
color: {$activeColor} !important;
}
.navbar .dropdown-menu {
background-color: {$backgroundColor};
border: none;
}
.navbar .dropdown-item {
color: {$textColor};
transition: background-color 0.3s ease, color 0.3s ease;
}
.navbar .dropdown-item:hover,
.navbar .dropdown-item:focus {
background-color: rgba(255, 134, 0, 0.1);
color: {$hoverColor};
}
.btn-lets-talk {
background-color: {$buttonBg};
color: {$buttonTextColor};
border: none;
padding: 0.5rem 1.5rem;
border-radius: 0.375rem;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-lets-talk:hover,
.btn-lets-talk:focus {
background-color: {$buttonHoverBg};
color: {$buttonTextColor};
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
</style>
STYLES;
}
private function buildScrollScript(array $data): string
{
$isSticky = $data['visibility']['is_sticky'] ?? false;
$hideOnScroll = $data['visibility']['hide_on_scroll'] ?? false;
if (!$isSticky && !$hideOnScroll) {
return '';
}
$script = '<script>';
if ($hideOnScroll) {
$script .= <<<JS
(function() {
let lastScrollTop = 0;
const navbar = document.querySelector('.roi-navbar-autohide');
if (navbar) {
window.addEventListener('scroll', function() {
let scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop > lastScrollTop && scrollTop > 100) {
// Scrolling down
navbar.style.transform = 'translateY(-100%)';
navbar.style.transition = 'transform 0.3s ease';
} else {
// Scrolling up
navbar.style.transform = 'translateY(0)';
}
lastScrollTop = scrollTop;
});
}
})();
JS;
}
// Smooth scroll for scroll buttons
$script .= <<<JS
document.addEventListener('DOMContentLoaded', function() {
const scrollLinks = document.querySelectorAll('a[data-scroll="true"]');
scrollLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href');
const targetElement = document.querySelector(targetId);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
});
JS;
$script .= '</script>';
return $script;
}
public function supports(string $componentType): bool
{
return $componentType === 'navbar';
}
}
/**
* Custom Walker for Bootstrap 5 Navigation
*/
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,206 @@
<?php
declare(strict_types=1);
namespace ROITheme\RelatedPosts\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class RelatedPostsFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder related-posts-form">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#query-tab" type="button">Consulta</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#layout-tab" type="button">Diseño</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#pagination-tab" type="button">Paginación</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
<div class="tab-pane fade" id="query-tab"><?php echo $this->buildQueryTab(); ?></div>
<div class="tab-pane fade" id="layout-tab"><?php echo $this->buildLayoutTab(); ?></div>
<div class="tab-pane fade" id="pagination-tab"><?php echo $this->buildPaginationTab(); ?></div>
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildVisibilityTab(): string
{
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
$sectionTitle = $this->data['visibility']['section_title'] ?? 'Descubre Más Contenido';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
<label class="form-check-label" for="is_enabled"><strong>Activar posts relacionados</strong></label>
</div>
<small class="form-text text-muted">Activa o desactiva el componente de posts relacionados</small>
</div>
<div class="form-group mb-4">
<label for="section_title"><strong>Título de la sección</strong></label>
<input type="text" class="form-control" id="section_title" name="visibility[section_title]" value="<?php echo esc_attr($sectionTitle); ?>" maxlength="200" required>
<small class="form-text text-muted">Título que aparece antes del grid de posts</small>
</div>
<?php
return ob_get_clean();
}
private function buildQueryTab(): string
{
$postsPerPage = $this->data['query']['posts_per_page'] ?? 12;
$postSelection = $this->data['query']['post_selection'] ?? 'category';
$excludeCurrent = $this->data['query']['exclude_current_post'] ?? true;
ob_start();
?>
<div class="form-group mb-4">
<label for="posts_per_page"><strong>Posts por página</strong></label>
<input type="number" class="form-control" id="posts_per_page" name="query[posts_per_page]" value="<?php echo esc_attr($postsPerPage); ?>" min="1" max="100" required>
<small class="form-text text-muted">Cantidad de posts a mostrar por página</small>
</div>
<div class="form-group mb-4">
<label for="post_selection"><strong>Criterio de selección</strong></label>
<select class="form-select" id="post_selection" name="query[post_selection]">
<option value="category" <?php selected($postSelection, 'category'); ?>>Misma categoría</option>
<option value="tags" <?php selected($postSelection, 'tags'); ?>>Mismos tags</option>
<option value="both" <?php selected($postSelection, 'both'); ?>>Categoría y tags</option>
<option value="recent" <?php selected($postSelection, 'recent'); ?>>Más recientes</option>
<option value="random" <?php selected($postSelection, 'random'); ?>>Aleatorio</option>
</select>
<small class="form-text text-muted">Cómo seleccionar los posts relacionados</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="exclude_current_post" name="query[exclude_current_post]" value="1" <?php checked($excludeCurrent, true); ?>>
<label class="form-check-label" for="exclude_current_post">Excluir post actual</label>
</div>
<small class="form-text text-muted">Excluye el post actual de los resultados</small>
</div>
<?php
return ob_get_clean();
}
private function buildLayoutTab(): string
{
$columns = $this->data['layout']['columns'] ?? '3';
$cardHeight = $this->data['layout']['card_height'] ?? 'equal';
ob_start();
?>
<div class="form-group mb-4">
<label for="columns"><strong>Número de columnas</strong></label>
<select class="form-select" id="columns" name="layout[columns]">
<option value="1" <?php selected($columns, '1'); ?>>1 columna</option>
<option value="2" <?php selected($columns, '2'); ?>>2 columnas</option>
<option value="3" <?php selected($columns, '3'); ?>>3 columnas</option>
<option value="4" <?php selected($columns, '4'); ?>>4 columnas</option>
</select>
<small class="form-text text-muted">Número de columnas en el grid</small>
</div>
<div class="form-group mb-4">
<label for="card_height"><strong>Altura de cards</strong></label>
<select class="form-select" id="card_height" name="layout[card_height]">
<option value="auto" <?php selected($cardHeight, 'auto'); ?>>Automática</option>
<option value="equal" <?php selected($cardHeight, 'equal'); ?>>Igual (centrado)</option>
</select>
<small class="form-text text-muted">Controla cómo se muestran las cards</small>
</div>
<?php
return ob_get_clean();
}
private function buildPaginationTab(): string
{
$showPagination = $this->data['pagination']['show_pagination'] ?? true;
$paginationPosition = $this->data['pagination']['pagination_position'] ?? 'center';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_pagination" name="pagination[show_pagination]" value="1" <?php checked($showPagination, true); ?>>
<label class="form-check-label" for="show_pagination"><strong>Mostrar paginación</strong></label>
</div>
<small class="form-text text-muted">Muestra u oculta la paginación</small>
</div>
<div class="form-group mb-4 conditional-field" data-depends-on="show_pagination" data-condition="checked">
<label for="pagination_position"><strong>Posición de la paginación</strong></label>
<select class="form-select" id="pagination_position" name="pagination[pagination_position]">
<option value="left" <?php selected($paginationPosition, 'left'); ?>>Izquierda</option>
<option value="center" <?php selected($paginationPosition, 'center'); ?>>Centro</option>
<option value="right" <?php selected($paginationPosition, 'right'); ?>>Derecha</option>
</select>
<small class="form-text text-muted">Alineación de la paginación</small>
</div>
<?php
return ob_get_clean();
}
private function buildStylesTab(): string
{
$containerClasses = $this->data['styles']['container_classes'] ?? 'my-5 related-posts';
$gridGap = $this->data['styles']['grid_gap'] ?? '4';
ob_start();
?>
<div class="form-group mb-4">
<label for="container_classes"><strong>Clases CSS del contenedor</strong></label>
<input type="text" class="form-control" id="container_classes" name="styles[container_classes]" value="<?php echo esc_attr($containerClasses); ?>">
<small class="form-text text-muted">Clases CSS adicionales para el contenedor principal</small>
</div>
<div class="form-group mb-4">
<label for="grid_gap"><strong>Espaciado del grid</strong></label>
<select class="form-select" id="grid_gap" name="styles[grid_gap]">
<option value="1" <?php selected($gridGap, '1'); ?>>Muy pequeño</option>
<option value="2" <?php selected($gridGap, '2'); ?>>Pequeño</option>
<option value="3" <?php selected($gridGap, '3'); ?>>Normal</option>
<option value="4" <?php selected($gridGap, '4'); ?>>Grande</option>
<option value="5" <?php selected($gridGap, '5'); ?>>Muy grande</option>
</select>
<small class="form-text text-muted">Espaciado entre cards</small>
</div>
<div class="alert alert-info">
<h6>Notas:</h6>
<ul class="mb-0">
<li>Los posts se seleccionan según el criterio elegido (categoría, tags, etc.)</li>
<li>Si no hay posts relacionados, se mostrarán los más recientes</li>
<li>La paginación solo aparece si hay más de una página de resultados</li>
</ul>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace ROITheme\RelatedPosts\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
use WP_Query;
final class RelatedPostsRenderer implements RendererInterface
{
private int $currentPostId;
private ?WP_Query $query = null;
public function __construct()
{
$this->currentPostId = get_the_ID() ?: 0;
}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$this->query = $this->buildQuery($data);
if (!$this->query->have_posts()) {
$this->query = $this->buildFallbackQuery($data);
}
if (!$this->query->have_posts()) {
wp_reset_postdata();
return '';
}
$containerClasses = $data['styles']['container_classes'] ?? 'my-5 related-posts';
$sectionTitle = $data['visibility']['section_title'] ?? 'Descubre Más Contenido';
ob_start();
?>
<div class="<?php echo esc_attr($containerClasses); ?>">
<h2 class="h3 mb-4"><?php echo esc_html($sectionTitle); ?></h2>
<?php echo $this->renderPostsGrid($data); ?>
<?php echo $this->renderPagination($data); ?>
</div>
<?php
wp_reset_postdata();
return ob_get_clean();
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function buildQuery(array $data): WP_Query
{
$postsPerPage = $data['query']['posts_per_page'] ?? 12;
$postSelection = $data['query']['post_selection'] ?? 'category';
$excludeCurrent = $data['query']['exclude_current_post'] ?? true;
$args = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $postsPerPage,
'paged' => get_query_var('paged') ? get_query_var('paged') : 1,
'ignore_sticky_posts' => true,
];
if ($excludeCurrent) {
$args['post__not_in'] = [$this->currentPostId];
}
switch ($postSelection) {
case 'category':
$args['category__in'] = wp_get_post_categories($this->currentPostId);
break;
case 'tags':
$tags = wp_get_post_tags($this->currentPostId, ['fields' => 'ids']);
if (!empty($tags)) {
$args['tag__in'] = $tags;
}
break;
case 'both':
$args['category__in'] = wp_get_post_categories($this->currentPostId);
$tags = wp_get_post_tags($this->currentPostId, ['fields' => 'ids']);
if (!empty($tags)) {
$args['tag__in'] = $tags;
}
break;
case 'random':
$args['orderby'] = 'rand';
break;
case 'recent':
default:
$args['orderby'] = 'date';
$args['order'] = 'DESC';
break;
}
return new WP_Query($args);
}
private function buildFallbackQuery(array $data): WP_Query
{
$postsPerPage = $data['query']['posts_per_page'] ?? 12;
$args = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $postsPerPage,
'orderby' => 'date',
'order' => 'DESC',
'post__not_in' => [$this->currentPostId],
'paged' => get_query_var('paged') ? get_query_var('paged') : 1,
];
return new WP_Query($args);
}
private function renderPostsGrid(array $data): string
{
$columns = $data['layout']['columns'] ?? '3';
$gridGap = 'g-' . ($data['styles']['grid_gap'] ?? '4');
$columnClass = $this->getColumnClass($columns);
ob_start();
?>
<div class="row <?php echo esc_attr($gridGap); ?>">
<?php
while ($this->query->have_posts()) {
$this->query->the_post();
echo $this->renderPostCard($data, $columnClass);
}
?>
</div>
<?php
return ob_get_clean();
}
private function getColumnClass(string $columns): string
{
$columnMap = [
'1' => 'col-12',
'2' => 'col-md-6',
'3' => 'col-md-4',
'4' => 'col-md-3',
];
return $columnMap[$columns] ?? 'col-md-4';
}
private function renderPostCard(array $data, string $columnClass): string
{
$cardHeight = $data['layout']['card_height'] ?? 'equal';
$heightClass = $cardHeight === 'equal' ? 'h-100' : '';
$textCenterClass = $cardHeight === 'equal' ? 'text-center' : '';
$dFlexClass = $cardHeight === 'equal' ? 'd-flex align-items-center justify-content-center' : '';
ob_start();
?>
<div class="<?php echo esc_attr($columnClass); ?>">
<a href="<?php the_permalink(); ?>" class="text-decoration-none">
<div class="card <?php echo esc_attr($heightClass); ?> shadow-sm">
<div class="card-body <?php echo esc_attr($dFlexClass); ?> p-4">
<h5 class="card-title h6 mb-0 <?php echo esc_attr($textCenterClass); ?>">
<?php the_title(); ?>
</h5>
</div>
</div>
</a>
</div>
<?php
return ob_get_clean();
}
private function renderPagination(array $data): string
{
if (!isset($data['pagination']['show_pagination']) || !$data['pagination']['show_pagination']) {
return '';
}
if ($this->query->max_num_pages <= 1) {
return '';
}
$paginationPosition = $data['pagination']['pagination_position'] ?? 'center';
$positionClass = 'justify-content-' . $paginationPosition;
$currentPage = max(1, get_query_var('paged'));
$totalPages = $this->query->max_num_pages;
ob_start();
?>
<nav aria-label="Navegación de posts relacionados" class="mt-5">
<ul class="pagination <?php echo esc_attr($positionClass); ?>">
<li class="page-item <?php echo $currentPage === 1 ? 'disabled' : ''; ?>">
<a class="page-link" href="<?php echo esc_url(get_pagenum_link(1)); ?>">Inicio</a>
</li>
<?php
$startPage = max(1, $currentPage - 2);
$endPage = min($totalPages, $startPage + 4);
if ($endPage - $startPage < 4) {
$startPage = max(1, $endPage - 4);
}
for ($i = $startPage; $i <= $endPage; $i++):
$activeClass = $i === $currentPage ? 'active' : '';
?>
<li class="page-item <?php echo esc_attr($activeClass); ?>">
<a class="page-link" href="<?php echo esc_url(get_pagenum_link($i)); ?>">
<?php echo esc_html($i); ?>
</a>
</li>
<?php endfor; ?>
<li class="page-item">
<a class="page-link" href="<?php echo esc_url(get_pagenum_link($endPage)); ?>">Ver más</a>
</li>
<li class="page-item <?php echo $currentPage === $totalPages ? 'disabled' : ''; ?>">
<a class="page-link" href="<?php echo esc_url(get_pagenum_link($totalPages)); ?>">Fin</a>
</li>
</ul>
</nav>
<?php
return ob_get_clean();
}
public function supports(string $componentType): bool
{
return $componentType === 'related-posts';
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace ROITheme\ShareButtons\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class ShareButtonsFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder share-buttons-form">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#networks-tab" type="button">Redes Sociales</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#button-styles-tab" type="button">Estilos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#advanced-tab" type="button">Avanzado</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
<div class="tab-pane fade" id="networks-tab"><?php echo $this->buildNetworksTab(); ?></div>
<div class="tab-pane fade" id="button-styles-tab"><?php echo $this->buildButtonStylesTab(); ?></div>
<div class="tab-pane fade" id="advanced-tab"><?php echo $this->buildAdvancedTab(); ?></div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildVisibilityTab(): string
{
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
$showLabel = $this->data['visibility']['show_label'] ?? true;
$labelText = $this->data['visibility']['label_text'] ?? 'Compartir:';
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
<label class="form-check-label" for="is_enabled"><strong>Activar botones de compartir</strong></label>
</div>
<small class="form-text text-muted">Activa o desactiva los botones de compartir en redes sociales</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_label" name="visibility[show_label]" value="1" <?php checked($showLabel, true); ?>>
<label class="form-check-label" for="show_label">Mostrar etiqueta</label>
</div>
<small class="form-text text-muted">Muestra u oculta el texto de etiqueta antes de los botones</small>
</div>
<div class="form-group mb-4 conditional-field" data-depends-on="show_label" data-condition="checked">
<label for="label_text">Texto de etiqueta</label>
<input type="text" class="form-control" id="label_text" name="visibility[label_text]" value="<?php echo esc_attr($labelText); ?>" maxlength="100">
<small class="form-text text-muted">Texto que aparece antes de los botones</small>
</div>
<?php
return ob_get_clean();
}
private function buildNetworksTab(): string
{
$enabledNetworks = $this->data['networks']['enabled_networks'] ?? ['facebook', 'instagram', 'linkedin', 'whatsapp', 'twitter', 'email'];
$showLabels = $this->data['networks']['show_network_labels'] ?? false;
$networks = [
'facebook' => 'Facebook',
'instagram' => 'Instagram',
'linkedin' => 'LinkedIn',
'whatsapp' => 'WhatsApp',
'twitter' => 'Twitter / X',
'email' => 'Email',
];
ob_start();
?>
<div class="form-group mb-4">
<label><strong>Redes sociales habilitadas</strong></label>
<p class="text-muted small">Selecciona qué redes sociales mostrar en los botones de compartir</p>
<?php foreach ($networks as $key => $label): ?>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="network_<?php echo esc_attr($key); ?>" name="networks[enabled_networks][]" value="<?php echo esc_attr($key); ?>" <?php checked(in_array($key, $enabledNetworks, true), true); ?>>
<label class="form-check-label" for="network_<?php echo esc_attr($key); ?>"><?php echo esc_html($label); ?></label>
</div>
<?php endforeach; ?>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_network_labels" name="networks[show_network_labels]" value="1" <?php checked($showLabels, true); ?>>
<label class="form-check-label" for="show_network_labels">Mostrar nombres de redes</label>
</div>
<small class="form-text text-muted">Muestra el nombre de la red social junto al icono</small>
</div>
<?php
return ob_get_clean();
}
private function buildButtonStylesTab(): string
{
$buttonStyle = $this->data['button_styles']['button_style'] ?? 'outline';
$buttonSize = $this->data['button_styles']['button_size'] ?? 'sm';
ob_start();
?>
<div class="form-group mb-4">
<label for="button_style"><strong>Estilo de botones</strong></label>
<select class="form-select" id="button_style" name="button_styles[button_style]">
<option value="outline" <?php selected($buttonStyle, 'outline'); ?>>Outline (contorno)</option>
<option value="solid" <?php selected($buttonStyle, 'solid'); ?>>Solid (relleno)</option>
</select>
<small class="form-text text-muted">Estilo visual de los botones</small>
</div>
<div class="form-group mb-4">
<label for="button_size"><strong>Tamaño de botones</strong></label>
<select class="form-select" id="button_size" name="button_styles[button_size]">
<option value="sm" <?php selected($buttonSize, 'sm'); ?>>Pequeño</option>
<option value="md" <?php selected($buttonSize, 'md'); ?>>Mediano</option>
<option value="lg" <?php selected($buttonSize, 'lg'); ?>>Grande</option>
</select>
<small class="form-text text-muted">Tamaño de los botones</small>
</div>
<?php
return ob_get_clean();
}
private function buildAdvancedTab(): string
{
$containerClasses = $this->data['advanced']['container_classes'] ?? 'my-5 py-4 border-top';
$wrapperClasses = $this->data['advanced']['buttons_wrapper_classes'] ?? 'd-flex gap-2 flex-wrap share-buttons';
ob_start();
?>
<div class="form-group mb-4">
<label for="container_classes"><strong>Clases CSS del contenedor</strong></label>
<input type="text" class="form-control" id="container_classes" name="advanced[container_classes]" value="<?php echo esc_attr($containerClasses); ?>">
<small class="form-text text-muted">Clases CSS adicionales para el contenedor principal</small>
</div>
<div class="form-group mb-4">
<label for="buttons_wrapper_classes"><strong>Clases CSS del wrapper de botones</strong></label>
<input type="text" class="form-control" id="buttons_wrapper_classes" name="advanced[buttons_wrapper_classes]" value="<?php echo esc_attr($wrapperClasses); ?>">
<small class="form-text text-muted">Clases CSS para el wrapper de los botones</small>
</div>
<div class="alert alert-info">
<h6>Notas:</h6>
<ul class="mb-0">
<li>Instagram no tiene API de compartir directa, se muestra como enlace inactivo (#)</li>
<li>Las URLs de compartir se generan automáticamente con el permalink del post</li>
<li>Todos los botones incluyen aria-label para lectores de pantalla</li>
</ul>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace ROITheme\ShareButtons\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class ShareButtonsRenderer implements RendererInterface
{
private array $networksConfig = [
'facebook' => [
'url_template' => 'https://www.facebook.com/sharer/sharer.php?u={url}',
'icon_class' => 'bi bi-facebook',
'button_class' => 'btn-outline-primary',
'aria_label' => 'Compartir en Facebook',
],
'instagram' => [
'url_template' => '#',
'icon_class' => 'bi bi-instagram',
'button_class' => 'btn-outline-danger',
'aria_label' => 'Compartir en Instagram',
],
'linkedin' => [
'url_template' => 'https://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}',
'icon_class' => 'bi bi-linkedin',
'button_class' => 'btn-outline-info',
'aria_label' => 'Compartir en LinkedIn',
],
'whatsapp' => [
'url_template' => 'https://wa.me/?text={title}%20{url}',
'icon_class' => 'bi bi-whatsapp',
'button_class' => 'btn-outline-success',
'aria_label' => 'Compartir en WhatsApp',
],
'twitter' => [
'url_template' => 'https://twitter.com/intent/tweet?url={url}&text={title}',
'icon_class' => 'bi bi-twitter-x',
'button_class' => 'btn-outline-dark',
'aria_label' => 'Compartir en X',
],
'email' => [
'url_template' => 'mailto:?subject={title}&body={url}',
'icon_class' => 'bi bi-envelope',
'button_class' => 'btn-outline-secondary',
'aria_label' => 'Compartir por Email',
],
];
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$enabledNetworks = $data['networks']['enabled_networks'] ?? [];
if (empty($enabledNetworks)) {
return '';
}
$containerClasses = $data['advanced']['container_classes'] ?? 'my-5 py-4 border-top';
$wrapperClasses = $data['advanced']['buttons_wrapper_classes'] ?? 'd-flex gap-2 flex-wrap share-buttons';
$html = sprintf('<div class="%s">', esc_attr($containerClasses));
if ($this->shouldShowLabel($data)) {
$labelText = $data['visibility']['label_text'] ?? 'Compartir:';
$html .= sprintf('<p class="mb-3 text-muted">%s</p>', esc_html($labelText));
}
$html .= sprintf('<div class="%s">', esc_attr($wrapperClasses));
$html .= $this->renderButtons($data, $enabledNetworks);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function shouldShowLabel(array $data): bool
{
return isset($data['visibility']['show_label']) &&
$data['visibility']['show_label'] === true;
}
private function renderButtons(array $data, array $enabledNetworks): string
{
$postUrl = get_permalink() ?: '';
$postTitle = get_the_title() ?: '';
$buttonStyle = $data['button_styles']['button_style'] ?? 'outline';
$buttonSize = $data['button_styles']['button_size'] ?? 'sm';
$showLabels = $data['networks']['show_network_labels'] ?? false;
$output = '';
foreach ($enabledNetworks as $network) {
if (!isset($this->networksConfig[$network])) {
continue;
}
$networkConfig = $this->networksConfig[$network];
$url = $this->buildShareUrl($network, $networkConfig['url_template'], $postUrl, $postTitle);
$buttonClass = $this->getButtonClass($networkConfig['button_class'], $buttonStyle, $buttonSize);
$label = $showLabels ? ' ' . esc_html(ucfirst($network)) : '';
$output .= sprintf(
'<a href="%s" class="%s" aria-label="%s" target="_blank" rel="noopener noreferrer">
<i class="%s"></i>%s
</a>',
esc_url($url),
esc_attr($buttonClass),
esc_attr($networkConfig['aria_label']),
esc_attr($networkConfig['icon_class']),
$label
);
}
return $output;
}
private function buildShareUrl(string $network, string $template, string $postUrl, string $postTitle): string
{
if ($network === 'instagram') {
return '#';
}
$replacements = [
'{url}' => urlencode($postUrl),
'{title}' => urlencode($postTitle),
];
return str_replace(array_keys($replacements), array_values($replacements), $template);
}
private function getButtonClass(string $networkClass, string $buttonStyle, string $buttonSize): string
{
$baseClass = 'btn';
$sizeClass = 'btn-' . $buttonSize;
if ($buttonStyle === 'solid') {
$networkClass = str_replace('btn-outline-', 'btn-', $networkClass);
}
return "{$baseClass} {$networkClass} {$sizeClass}";
}
public function supports(string $componentType): bool
{
return $componentType === 'share-buttons';
}
}

View File

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
/**
* ComponentDefaultsRepositoryInterface - Contrato para valores por defecto de componentes
*
* RESPONSABILIDAD: Definir contrato para gestión de configuración por defecto de componentes
*
* PROPÓSITO:
* - Almacenar configuración "factory" de cada tipo de componente
* - Permitir resetear componentes a valores por defecto
* - Proporcionar plantillas para nuevos componentes
*
* CASO DE USO:
* ```php
* // Crear nuevo componente top_bar con valores por defecto
* $defaults = $defaultsRepo->getByName(ComponentName::fromString('top_bar'));
* $component = new Component(
* ComponentName::fromString('top_bar'),
* $defaults // configuración por defecto
* );
*
* // Resetear componente a valores por defecto
* $component = $component->updateConfiguration($defaults);
* ```
*
* @package ROITheme\Domain\Contracts
*/
interface ComponentDefaultsRepositoryInterface
{
/**
* Obtener configuración por defecto de un componente
*
* @param ComponentName $name Nombre del componente
* @return ComponentConfiguration Configuración por defecto
*/
public function getByName(ComponentName $name): ComponentConfiguration;
/**
* Guardar configuración por defecto para un componente
*
* @param ComponentName $name Nombre del componente
* @param ComponentConfiguration $configuration Configuración por defecto
* @return void
*/
public function save(ComponentName $name, ComponentConfiguration $configuration): void;
/**
* Verificar si existen defaults para un componente
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool;
/**
* Obtener todos los defaults
*
* @return array<string, ComponentConfiguration> Array asociativo nombre => configuración
*/
public function findAll(): array;
/**
* Eliminar defaults de un componente
*
* @param ComponentName $name
* @return bool True si se eliminó, false si no existía
*/
public function delete(ComponentName $name): bool;
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\ValueObjects\ComponentName;
use ROITheme\Component\Domain\Exceptions\ComponentNotFoundException;
/**
* ComponentRepositoryInterface - Contrato para persistencia de componentes
*
* RESPONSABILIDAD: Definir contrato para operaciones CRUD de componentes
*
* INVERSIÓN DE DEPENDENCIAS:
* - Esta interfaz está en Domain (núcleo)
* - Infrastructure implementa esta interfaz
* - Application depende de esta interfaz (NO de la implementación)
*
* IMPLEMENTACIONES ESPERADAS:
* - WordPressComponentRepository (usa wpdb y tablas WP)
* - InMemoryComponentRepository (para testing)
* - FileSystemComponentRepository (futuro: archivos JSON)
*
* PRINCIPIOS:
* - Métodos retornan entidades de dominio (Component)
* - Parámetros son Value Objects o primitivos
* - Excepciones son de dominio
* - Sin conocimiento de WordPress/BD
*
* USO:
* ```php
* // En Application Layer
* class SaveComponentUseCase {
* public function __construct(
* private ComponentRepositoryInterface $repository
* ) {}
*
* public function execute(SaveComponentRequest $request): void {
* $component = new Component(...);
* $this->repository->save($component);
* }
* }
* ```
*
* @package ROITheme\Domain\Contracts
*/
interface ComponentRepositoryInterface
{
/**
* Guardar o actualizar un componente
*
* Si el componente ya existe (por nombre), se actualiza.
* Si no existe, se crea.
*
* @param Component $component Componente a guardar
* @return Component Componente guardado (con timestamps actualizados)
*/
public function save(Component $component): Component;
/**
* Buscar componente por nombre
*
* @param ComponentName $name Nombre del componente
* @return Component|null Componente encontrado o null
*/
public function findByName(ComponentName $name): ?Component;
/**
* Obtener componente por nombre (lanza excepción si no existe)
*
* @param ComponentName $name
* @return Component
* @throws ComponentNotFoundException
*/
public function getByName(ComponentName $name): Component;
/**
* Obtener todos los componentes
*
* @return Component[] Array de componentes
*/
public function findAll(): array;
/**
* Obtener componentes habilitados
*
* @return Component[] Array de componentes habilitados
*/
public function findEnabled(): array;
/**
* Verificar si existe un componente con el nombre dado
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool;
/**
* Eliminar un componente
*
* @param ComponentName $name Nombre del componente a eliminar
* @return bool True si se eliminó, false si no existía
*/
public function delete(ComponentName $name): bool;
/**
* Obtener cantidad total de componentes
*
* @return int
*/
public function count(): int;
/**
* Obtener componentes por grupo de configuración
*
* Ejemplo: Obtener todos los componentes que tienen configuración de 'content'
*
* @param string $group Grupo de configuración (visibility, content, styles, general)
* @return Component[]
*/
public function findByConfigGroup(string $group): array;
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain;
/**
* ValidationServiceInterface - Contrato para servicio de validación
*
* RESPONSABILIDAD: Definir contrato para validación de datos de entrada
*
* PROPÓSITO:
* - Validar datos antes de crear entidades de dominio
* - Sanitizar entrada del usuario
* - Retornar errores de validación estructurados
*
* UBICACIÓN EN ARQUITECTURA:
* - Interfaz definida en Domain
* - Implementación en Infrastructure (puede usar WordPress functions)
* - Usado por Application Layer (Use Cases)
*
* IMPLEMENTACIONES ESPERADAS:
* - WordPressValidationService (usa funciones de sanitización de WordPress)
* - StrictValidationService (validación más estricta para prod)
* - LenientValidationService (validación más permisiva para dev)
*
* USO:
* ```php
* // En Application Layer
* class SaveComponentUseCase {
* public function __construct(
* private ValidationServiceInterface $validator
* ) {}
*
* public function execute(SaveComponentRequest $request): void {
* $result = $this->validator->validate(
* $request->getData(),
* $request->getComponentName()
* );
*
* if (!$result->isValid()) {
* throw new ValidationException($result->getErrors());
* }
*
* // ... crear componente con datos validados
* }
* }
* ```
*
* @package ROITheme\Domain\Contracts
*/
interface ValidationServiceInterface
{
/**
* Validar datos de un componente
*
* @param array $data Datos a validar
* @param string $componentName Nombre del componente (para reglas específicas)
* @return ValidationResult Resultado de validación
*/
public function validate(array $data, string $componentName): ValidationResult;
/**
* Sanitizar datos de entrada
*
* @param array $data Datos a sanitizar
* @param string $componentName Nombre del componente
* @return array Datos sanitizados
*/
public function sanitize(array $data, string $componentName): array;
/**
* Validar una URL
*
* @param string $url URL a validar
* @return bool
*/
public function isValidUrl(string $url): bool;
/**
* Validar un color hexadecimal
*
* @param string $color Color a validar (ej: #000000)
* @return bool
*/
public function isValidColor(string $color): bool;
/**
* Validar nombre de componente
*
* @param string $name Nombre a validar
* @return bool
*/
public function isValidComponentName(string $name): bool;
}
/**
* ValidationResult - Value Object para resultado de validación
*
* RESPONSABILIDAD: Encapsular resultado de una validación
*
* @package ROITheme\Domain\Contracts
*/
final class ValidationResult
{
/**
* Constructor
*
* @param bool $isValid Si la validación pasó
* @param array $errors Array de errores (campo => mensaje)
* @param array $sanitizedData Datos sanitizados
*/
public function __construct(
private bool $isValid,
private array $errors = [],
private array $sanitizedData = []
) {}
/**
* Verificar si la validación pasó
*
* @return bool
*/
public function isValid(): bool
{
return $this->isValid;
}
/**
* Obtener errores de validación
*
* @return array Array asociativo campo => mensaje
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Obtener datos sanitizados
*
* @return array
*/
public function getSanitizedData(): array
{
return $this->sanitizedData;
}
/**
* Verificar si hay error en campo específico
*
* @param string $field
* @return bool
*/
public function hasError(string $field): bool
{
return isset($this->errors[$field]);
}
/**
* Obtener error de un campo específico
*
* @param string $field
* @return string|null
*/
public function getError(string $field): ?string
{
return $this->errors[$field] ?? null;
}
/**
* Factory: Resultado exitoso
*
* @param array $sanitizedData
* @return self
*/
public static function success(array $sanitizedData): self
{
return new self(
isValid: true,
errors: [],
sanitizedData: $sanitizedData
);
}
/**
* Factory: Resultado con errores
*
* @param array $errors
* @return self
*/
public static function failure(array $errors): self
{
return new self(
isValid: false,
errors: $errors,
sanitizedData: []
);
}
}

View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace ROITheme\TableOfContents\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class TableOfContentsFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
ob_start();
?>
<div class="roi-form-builder table-of-contents-form">
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#config-tab" type="button">Configuración</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#manual-items-tab" type="button">Items Manuales</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
<div class="tab-pane fade" id="config-tab"><?php echo $this->buildConfigTab(); ?></div>
<div class="tab-pane fade" id="manual-items-tab"><?php echo $this->buildManualItemsTab(); ?></div>
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
</div>
</div>
<?php
return ob_get_clean();
}
private function buildVisibilityTab(): string
{
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
$sticky = $this->data['visibility']['sticky'] ?? true;
$showOnMobile = $this->data['visibility']['show_on_mobile'] ?? false;
ob_start();
?>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
<label class="form-check-label" for="is_enabled"><strong>Activar tabla de contenido</strong></label>
</div>
<small class="form-text text-muted">Activa o desactiva el componente de tabla de contenido</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="sticky" name="visibility[sticky]" value="1" <?php checked($sticky, true); ?>>
<label class="form-check-label" for="sticky">Posición sticky (fija al hacer scroll)</label>
</div>
<small class="form-text text-muted">Mantiene el TOC visible mientras el usuario navega</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_on_mobile" name="visibility[show_on_mobile]" value="1" <?php checked($showOnMobile, true); ?>>
<label class="form-check-label" for="show_on_mobile">Mostrar en dispositivos móviles</label>
</div>
<small class="form-text text-muted">Muestra el TOC en pantallas pequeñas</small>
</div>
<?php
return ob_get_clean();
}
private function buildConfigTab(): string
{
$title = $this->data['config']['title'] ?? 'Tabla de Contenido';
$headingLevels = $this->data['config']['heading_levels'] ?? ['h2', 'h3'];
$autoGenerate = $this->data['config']['auto_generate'] ?? true;
$offsetTop = $this->data['config']['offset_top'] ?? 100;
$smoothScroll = $this->data['config']['smooth_scroll'] ?? true;
$maxHeight = $this->data['config']['max_height'] ?? 'calc(100vh - 400px)';
$customCssClass = $this->data['config']['custom_css_class'] ?? '';
ob_start();
?>
<div class="form-group mb-4">
<label for="title"><strong>Título del TOC</strong></label>
<input type="text" class="form-control" id="title" name="config[title]" value="<?php echo esc_attr($title); ?>" maxlength="100" required>
<small class="form-text text-muted">Título que aparece en el encabezado del TOC</small>
</div>
<div class="form-group mb-4">
<label><strong>Niveles de encabezados</strong></label>
<small class="form-text text-muted d-block mb-2">Selecciona qué niveles incluir en el TOC</small>
<?php
$availableLevels = ['h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6'];
foreach ($availableLevels as $level => $label):
?>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="heading_<?php echo $level; ?>" name="config[heading_levels][]" value="<?php echo esc_attr($level); ?>" <?php checked(in_array($level, $headingLevels)); ?>>
<label class="form-check-label" for="heading_<?php echo $level; ?>"><?php echo esc_html($label); ?></label>
</div>
<?php endforeach; ?>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="auto_generate" name="config[auto_generate]" value="1" <?php checked($autoGenerate, true); ?>>
<label class="form-check-label" for="auto_generate"><strong>Generar automáticamente desde contenido</strong></label>
</div>
<small class="form-text text-muted">Si está desactivado, usa items manuales</small>
</div>
<div class="form-group mb-4">
<label for="offset_top"><strong>Offset top (px)</strong></label>
<input type="number" class="form-control" id="offset_top" name="config[offset_top]" value="<?php echo esc_attr($offsetTop); ?>" min="0" max="500" step="10">
<small class="form-text text-muted">Offset desde el top para el ScrollSpy</small>
</div>
<div class="form-group mb-4">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="smooth_scroll" name="config[smooth_scroll]" value="1" <?php checked($smoothScroll, true); ?>>
<label class="form-check-label" for="smooth_scroll">Scroll suave</label>
</div>
<small class="form-text text-muted">Activar scroll suave al hacer clic en enlaces</small>
</div>
<div class="form-group mb-4">
<label for="max_height"><strong>Altura máxima</strong></label>
<input type="text" class="form-control" id="max_height" name="config[max_height]" value="<?php echo esc_attr($maxHeight); ?>">
<small class="form-text text-muted">CSS válido (ej: 500px, calc(100vh - 200px))</small>
</div>
<div class="form-group mb-4">
<label for="custom_css_class"><strong>Clase CSS personalizada</strong></label>
<input type="text" class="form-control" id="custom_css_class" name="config[custom_css_class]" value="<?php echo esc_attr($customCssClass); ?>">
<small class="form-text text-muted">Opcional: agregar clase CSS adicional</small>
</div>
<?php
return ob_get_clean();
}
private function buildManualItemsTab(): string
{
$items = $this->data['manual_items']['items'] ?? [];
ob_start();
?>
<div class="alert alert-info">
<strong>Nota:</strong> Los items manuales solo se usan si "Generar automáticamente" está desactivado en la pestaña Configuración.
</div>
<div class="manual-items-list mb-3">
<?php
if (empty($items)) {
$items = [['text' => '', 'anchor' => '', 'level' => 2]];
}
foreach ($items as $index => $item):
?>
<div class="card mb-3 manual-item">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Item #<?php echo $index + 1; ?></strong>
<button type="button" class="btn btn-sm btn-outline-danger remove-manual-item">
<i class="bi bi-trash"></i> Eliminar
</button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<label>Texto del enlace</label>
<input type="text" class="form-control" name="manual_items[items][<?php echo $index; ?>][text]" value="<?php echo esc_attr($item['text'] ?? ''); ?>" placeholder="Ej: Introducción">
</div>
<div class="col-md-4">
<label>ID de ancla (sin #)</label>
<input type="text" class="form-control" name="manual_items[items][<?php echo $index; ?>][anchor]" value="<?php echo esc_attr($item['anchor'] ?? ''); ?>" placeholder="Ej: introduccion">
</div>
<div class="col-md-3">
<label>Nivel</label>
<select class="form-select" name="manual_items[items][<?php echo $index; ?>][level]">
<?php for ($i = 2; $i <= 6; $i++): ?>
<option value="<?php echo $i; ?>" <?php selected($item['level'] ?? 2, $i); ?>>H<?php echo $i; ?></option>
<?php endfor; ?>
</select>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-secondary add-manual-item">
<i class="bi bi-plus-circle"></i> Agregar Item
</button>
<script>
jQuery(document).ready(function($) {
$('.add-manual-item').on('click', function() {
const container = $('.manual-items-list');
const index = container.find('.manual-item').length;
const itemHTML = `
<div class="card mb-3 manual-item">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Item #${index + 1}</strong>
<button type="button" class="btn btn-sm btn-outline-danger remove-manual-item">
<i class="bi bi-trash"></i> Eliminar
</button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<label>Texto del enlace</label>
<input type="text" class="form-control" name="manual_items[items][${index}][text]" placeholder="Ej: Introducción">
</div>
<div class="col-md-4">
<label>ID de ancla (sin #)</label>
<input type="text" class="form-control" name="manual_items[items][${index}][anchor]" placeholder="Ej: introduccion">
</div>
<div class="col-md-3">
<label>Nivel</label>
<select class="form-select" name="manual_items[items][${index}][level]">
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
</select>
</div>
</div>
</div>
</div>
`;
container.append(itemHTML);
});
$(document).on('click', '.remove-manual-item', function() {
$(this).closest('.manual-item').remove();
$('.manual-item').each(function(index) {
$(this).find('.card-header strong').text('Item #' + (index + 1));
});
});
});
</script>
<?php
return ob_get_clean();
}
private function buildStylesTab(): string
{
$backgroundColor = $this->data['styles']['background_color'] ?? '#ffffff';
$borderColor = $this->data['styles']['border_color'] ?? '#E6E9ED';
$titleColor = $this->data['styles']['title_color'] ?? '#0E2337';
$linkColor = $this->data['styles']['link_color'] ?? '#6B7280';
$linkHoverColor = $this->data['styles']['link_hover_color'] ?? '#0E2337';
$activeBorderColor = $this->data['styles']['active_border_color'] ?? '#0E2337';
$activeBgColor = $this->data['styles']['active_bg_color'] ?? '#F9FAFB';
ob_start();
?>
<div class="row">
<div class="col-md-6 mb-4">
<label for="background_color"><strong>Color de fondo</strong></label>
<input type="color" class="form-control form-control-color" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($backgroundColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="border_color"><strong>Color de borde</strong></label>
<input type="color" class="form-control form-control-color" id="border_color" name="styles[border_color]" value="<?php echo esc_attr($borderColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="title_color"><strong>Color del título</strong></label>
<input type="color" class="form-control form-control-color" id="title_color" name="styles[title_color]" value="<?php echo esc_attr($titleColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="link_color"><strong>Color de enlaces</strong></label>
<input type="color" class="form-control form-control-color" id="link_color" name="styles[link_color]" value="<?php echo esc_attr($linkColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="link_hover_color"><strong>Color de enlaces (hover)</strong></label>
<input type="color" class="form-control form-control-color" id="link_hover_color" name="styles[link_hover_color]" value="<?php echo esc_attr($linkHoverColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="active_border_color"><strong>Color de borde activo</strong></label>
<input type="color" class="form-control form-control-color" id="active_border_color" name="styles[active_border_color]" value="<?php echo esc_attr($activeBorderColor); ?>">
</div>
<div class="col-md-6 mb-4">
<label for="active_bg_color"><strong>Color de fondo activo</strong></label>
<input type="color" class="form-control form-control-color" id="active_bg_color" name="styles[active_bg_color]" value="<?php echo esc_attr($activeBgColor); ?>">
</div>
</div>
<div class="alert alert-info">
<h6>Notas sobre el TOC:</h6>
<ul class="mb-0">
<li>El ScrollSpy requiere Bootstrap 5 cargado en el sitio</li>
<li>Los IDs se generan automáticamente desde los encabezados del contenido</li>
<li>El TOC es responsive y se adapta a pantallas pequeñas</li>
</ul>
</div>
<?php
return ob_get_clean();
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace ROITheme\TableOfContents\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
use DOMDocument;
use DOMXPath;
final class TableOfContentsRenderer implements RendererInterface
{
private int $currentPostId;
public function __construct()
{
$this->currentPostId = get_the_ID() ?: 0;
}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$autoGenerate = $data['config']['auto_generate'] ?? true;
$tocItems = $autoGenerate
? $this->generateTocFromContent($data)
: $this->getManualItems($data);
if (empty($tocItems)) {
return '';
}
$componentId = $component->getId();
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
$stickyClass = ($data['visibility']['sticky'] ?? true) ? 'sidebar-sticky' : '';
$mobileClass = !($data['visibility']['show_on_mobile'] ?? false) ? 'd-none d-lg-block' : '';
$title = $data['config']['title'] ?? 'Tabla de Contenido';
$maxHeight = $data['config']['max_height'] ?? 'calc(100vh - 400px)';
$offsetTop = $data['config']['offset_top'] ?? 100;
ob_start();
?>
<?php if (!empty($customStyles)): ?>
<style>
<?php echo $customStyles; ?>
</style>
<?php endif; ?>
<div id="<?php echo esc_attr($componentId); ?>"
class="toc-container <?php echo esc_attr($stickyClass . ' ' . $mobileClass . ' ' . ($data['config']['custom_css_class'] ?? '')); ?>"
data-bs-spy="scroll"
data-bs-offset="<?php echo esc_attr($offsetTop); ?>"
style="max-height: <?php echo esc_attr($maxHeight); ?>;">
<h4><?php echo esc_html($title); ?></h4>
<ol class="list-unstyled toc-list">
<?php foreach ($tocItems as $item): ?>
<?php
$text = $item['text'] ?? '';
$anchor = $item['anchor'] ?? '';
$level = $item['level'] ?? 2;
if (empty($text) || empty($anchor)) {
continue;
}
$indentClass = $level > 2 ? 'toc-level-' . $level : '';
?>
<li class="<?php echo esc_attr($indentClass); ?>">
<a href="#<?php echo esc_attr($anchor); ?>"
class="toc-link"
data-level="<?php echo esc_attr($level); ?>">
<?php echo esc_html($text); ?>
</a>
</li>
<?php endforeach; ?>
</ol>
</div>
<?php if ($data['config']['smooth_scroll'] ?? true): ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tocLinks = document.querySelectorAll('.toc-link');
tocLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href');
const targetElement = document.querySelector(targetId);
if (targetElement) {
const offsetTop = <?php echo intval($offsetTop); ?>;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - offsetTop;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
});
});
if (typeof bootstrap === 'undefined') {
const sections = document.querySelectorAll('[id]');
const navLinks = document.querySelectorAll('.toc-link');
window.addEventListener('scroll', () => {
let current = '';
sections.forEach(section => {
const sectionTop = section.offsetTop;
if (pageYOffset >= (sectionTop - <?php echo intval($offsetTop) + 50; ?>)) {
current = section.getAttribute('id');
}
});
navLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + current) {
link.classList.add('active');
}
});
});
}
});
</script>
<?php endif; ?>
<?php
return ob_get_clean();
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function generateTocFromContent(array $data): array
{
global $post;
if (!$post || empty($post->post_content)) {
return [];
}
$headingLevels = $data['config']['heading_levels'] ?? ['h2', 'h3'];
$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);
$xpathQuery = implode(' | ', array_map(function($level) {
return '//' . $level;
}, $headingLevels));
$headings = $xpath->query($xpathQuery);
if ($headings->length === 0) {
return [];
}
$tocItems = [];
$headingCounter = [];
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');
$anchor = !empty($existingId)
? $existingId
: $this->generateAnchorId($text, $headingCounter);
$tocItems[] = [
'text' => $text,
'anchor' => $anchor,
'level' => $level
];
}
return $tocItems;
}
private function getManualItems(array $data): array
{
return $data['manual_items']['items'] ?? [];
}
private function generateAnchorId(string $text, array &$counter): string
{
$id = strtolower($text);
$id = remove_accents($id);
$id = preg_replace('/[^a-z0-9]+/', '-', $id);
$id = trim($id, '-');
$baseId = $id;
$count = 1;
while (isset($counter[$id])) {
$id = $baseId . '-' . $count;
$count++;
}
$counter[$id] = true;
return $id;
}
private function generateCustomStyles(string $componentId, array $styles): string
{
if (empty($styles)) {
return '';
}
$css = [];
if (isset($styles['background_color'])) {
$css[] = "#$componentId.toc-container { background: {$styles['background_color']}; }";
}
if (isset($styles['border_color'])) {
$css[] = "#$componentId.toc-container { border-color: {$styles['border_color']}; }";
}
if (isset($styles['title_color'])) {
$css[] = "#$componentId.toc-container h4 { color: {$styles['title_color']}; }";
}
if (isset($styles['link_color'])) {
$css[] = "#$componentId .toc-link { color: {$styles['link_color']}; }";
}
if (isset($styles['link_hover_color'])) {
$css[] = "#$componentId .toc-link:hover { color: {$styles['link_hover_color']}; }";
}
if (isset($styles['active_border_color'])) {
$css[] = "#$componentId .toc-link.active { border-left-color: {$styles['active_border_color']}; }";
}
if (isset($styles['active_bg_color'])) {
$css[] = "#$componentId .toc-link.active { background-color: {$styles['active_bg_color']}; }";
}
return implode("\n", $css);
}
public function supports(string $componentType): bool
{
return $componentType === 'table-of-contents';
}
}

View File

@@ -0,0 +1,691 @@
<?php
declare(strict_types=1);
namespace ROITheme\TopNotificationBar\Infrastructure\Presentation\Admin;
use ROITheme\Component\Domain\FormBuilderInterface;
final class TopNotificationBarFormBuilder implements FormBuilderInterface
{
private string $componentId;
private array $data;
public function __construct(string $componentId, array $data = [])
{
$this->componentId = $componentId;
$this->data = $data;
}
public function build(): string
{
$data = $this->data;
$componentId = $this->componentId;
$html = '<div class="roi-form-builder roi-top-notification-bar-form">';
// Sección de Visibilidad
$html .= $this->buildVisibilitySection($data, $componentId);
// Sección de Contenido
$html .= $this->buildContentSection($data, $componentId);
// Sección de Estilos
$html .= $this->buildStylesSection($data, $componentId);
// Vista previa
$html .= $this->buildPreviewSection($data);
$html .= '</div>';
// Agregar scripts de formulario
$html .= $this->buildFormScripts($componentId);
return $html;
}
private function buildVisibilitySection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="visibility">';
$html .= '<h3 class="roi-form-section-title">Visibilidad</h3>';
$html .= '<div class="roi-form-section-content">';
// Is Enabled
$isEnabled = $data['visibility']['is_enabled'] ?? true;
$html .= $this->buildToggle(
'is_enabled',
'Mostrar barra de notificación',
$isEnabled,
$componentId,
'Activa o desactiva la barra de notificación superior'
);
// Show On Pages
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
$html .= $this->buildSelect(
'show_on_pages',
'Mostrar en',
$showOn,
[
'all' => 'Todas las páginas',
'home' => 'Solo página de inicio',
'posts' => 'Solo posts individuales',
'pages' => 'Solo páginas',
'custom' => 'Páginas específicas'
],
$componentId,
'Define en qué páginas se mostrará la barra'
);
// Custom Page IDs
$customPageIds = $data['visibility']['custom_page_ids'] ?? '';
$html .= $this->buildTextField(
'custom_page_ids',
'IDs de páginas específicas',
$customPageIds,
$componentId,
'IDs de páginas separados por comas',
'Ej: 1,5,10',
['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']
);
// Hide On Mobile
$hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false;
$html .= $this->buildToggle(
'hide_on_mobile',
'Ocultar en dispositivos móviles',
$hideOnMobile,
$componentId,
'Oculta la barra en pantallas menores a 768px'
);
// Is Dismissible
$isDismissible = $data['visibility']['is_dismissible'] ?? false;
$html .= $this->buildToggle(
'is_dismissible',
'Permitir cerrar',
$isDismissible,
$componentId,
'Agrega botón X para que el usuario pueda cerrar la barra'
);
// Dismissible Cookie Days
$cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7;
$html .= $this->buildNumberField(
'dismissible_cookie_days',
'Días antes de volver a mostrar',
$cookieDays,
$componentId,
'Días que permanece oculta después de cerrarla',
1,
365,
['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildContentSection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="content">';
$html .= '<h3 class="roi-form-section-title">Contenido</h3>';
$html .= '<div class="roi-form-section-content">';
// Icon Type
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
$html .= $this->buildSelect(
'icon_type',
'Tipo de ícono',
$iconType,
[
'bootstrap' => 'Bootstrap Icons',
'custom' => 'Imagen personalizada',
'none' => 'Sin ícono'
],
$componentId,
'Selecciona el tipo de ícono a mostrar'
);
// Bootstrap Icon
$bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
$html .= $this->buildTextField(
'bootstrap_icon',
'Clase de ícono Bootstrap',
$bootstrapIcon,
$componentId,
'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)',
'Ej: bi-megaphone-fill',
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap']
);
// Custom Icon URL
$customIconUrl = $data['content']['custom_icon_url'] ?? '';
$html .= $this->buildMediaField(
'custom_icon_url',
'Imagen personalizada',
$customIconUrl,
$componentId,
'Sube una imagen personalizada (recomendado: PNG 24x24px)',
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom']
);
// Announcement Label
$announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:';
$html .= $this->buildTextField(
'announcement_label',
'Etiqueta del anuncio',
$announcementLabel,
$componentId,
'Texto destacado en negrita antes del mensaje',
'Ej: Nuevo:, Importante:, Aviso:'
);
// Announcement Text
$announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
$html .= $this->buildTextArea(
'announcement_text',
'Texto del anuncio',
$announcementText,
$componentId,
'Mensaje principal del anuncio (máximo 200 caracteres)',
3
);
// Link Enabled
$linkEnabled = $data['content']['link_enabled'] ?? true;
$html .= $this->buildToggle(
'link_enabled',
'Mostrar enlace',
$linkEnabled,
$componentId,
'Activa o desactiva el enlace de acción'
);
// Link Text
$linkText = $data['content']['link_text'] ?? 'Ver Catálogo';
$html .= $this->buildTextField(
'link_text',
'Texto del enlace',
$linkText,
$componentId,
'Texto del enlace de acción',
'',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
// Link URL
$linkUrl = $data['content']['link_url'] ?? '#';
$html .= $this->buildUrlField(
'link_url',
'URL del enlace',
$linkUrl,
$componentId,
'URL de destino del enlace',
'https://',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
// Link Target
$linkTarget = $data['content']['link_target'] ?? '_self';
$html .= $this->buildSelect(
'link_target',
'Abrir enlace en',
$linkTarget,
[
'_self' => 'Misma ventana',
'_blank' => 'Nueva ventana'
],
$componentId,
'Define cómo se abrirá el enlace',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildStylesSection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="styles">';
$html .= '<h3 class="roi-form-section-title">Estilos</h3>';
$html .= '<div class="roi-form-section-content">';
// Background Color
$bgColor = $data['styles']['background_color'] ?? '#FF8600';
$html .= $this->buildColorField(
'background_color',
'Color de fondo',
$bgColor,
$componentId,
'Color de fondo de la barra (por defecto: orange primary)'
);
// Text Color
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField(
'text_color',
'Color del texto',
$textColor,
$componentId,
'Color del texto del anuncio'
);
// Link Color
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
$html .= $this->buildColorField(
'link_color',
'Color del enlace',
$linkColor,
$componentId,
'Color del enlace de acción'
);
// Font Size
$fontSize = $data['styles']['font_size'] ?? 'small';
$html .= $this->buildSelect(
'font_size',
'Tamaño de fuente',
$fontSize,
[
'extra-small' => 'Muy pequeño (0.75rem)',
'small' => 'Pequeño (0.875rem)',
'normal' => 'Normal (1rem)',
'large' => 'Grande (1.125rem)'
],
$componentId,
'Tamaño del texto del anuncio'
);
// Padding Vertical
$padding = $data['styles']['padding_vertical'] ?? 'normal';
$html .= $this->buildSelect(
'padding_vertical',
'Padding vertical',
$padding,
[
'compact' => 'Compacto (0.5rem)',
'normal' => 'Normal (0.75rem)',
'spacious' => 'Espacioso (1rem)'
],
$componentId,
'Espaciado vertical interno de la barra'
);
// Text Alignment
$alignment = $data['styles']['text_alignment'] ?? 'center';
$html .= $this->buildSelect(
'text_alignment',
'Alineación del texto',
$alignment,
[
'left' => 'Izquierda',
'center' => 'Centro',
'right' => 'Derecha'
],
$componentId,
'Alineación del contenido de la barra'
);
// Animation Enabled
$animationEnabled = $data['styles']['animation_enabled'] ?? false;
$html .= $this->buildToggle(
'animation_enabled',
'Activar animación',
$animationEnabled,
$componentId,
'Activa animación de entrada al cargar la página'
);
// Animation Type
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
$html .= $this->buildSelect(
'animation_type',
'Tipo de animación',
$animationType,
[
'slide-down' => 'Deslizar desde arriba',
'fade-in' => 'Aparecer gradualmente'
],
$componentId,
'Tipo de animación de entrada',
['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildPreviewSection(array $data): string
{
$html = '<div class="roi-form-section roi-preview-section">';
$html .= '<h3 class="roi-form-section-title">Vista Previa</h3>';
$html .= '<div class="roi-form-section-content">';
$html .= '<div id="roi-component-preview" class="border rounded p-3 bg-light">';
$html .= '<p class="text-muted">La vista previa se actualizará automáticamente al modificar los campos.</p>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string
{
$fieldId = "roi_{$componentId}_{$name}";
$checked = $value ? 'checked' : '';
$html = '<div class="roi-form-field roi-form-field-toggle mb-3">';
$html .= '<div class="form-check form-switch">';
$html .= sprintf(
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s]" value="1" %s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$checked
);
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '</div>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-text mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" placeholder="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
esc_attr($placeholder),
$attrString
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-textarea mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<textarea class="form-control" id="%s" name="roi_component[%s][%s]" rows="%d"%s>%s</textarea>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$rows,
$attrString,
esc_textarea($value)
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-select mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<select class="form-select" id="%s" name="roi_component[%s][%s]"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$attrString
);
foreach ($options as $optValue => $optLabel) {
$selected = ($value === $optValue) ? 'selected' : '';
$html .= sprintf(
'<option value="%s" %s>%s</option>',
esc_attr($optValue),
$selected,
esc_html($optLabel)
);
}
$html .= '</select>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-number mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrs['type'] = 'number';
if ($min !== null) {
$attrs['min'] = $min;
}
if ($max !== null) {
$attrs['max'] = $max;
}
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input class="form-control" id="%s" name="roi_component[%s][%s]" value="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
$attrString
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
{
$attrs['type'] = 'url';
return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs);
}
private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-color mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$html .= sprintf(
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s]" value="%s">',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value)
);
$html .= sprintf(
'<input type="text" class="form-control" value="%s" readonly>',
esc_attr($value)
);
$html .= '</div>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-media mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" readonly%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
$attrString
);
$html .= sprintf(
'<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>',
esc_attr($fieldId)
);
$html .= '</div>';
if (!empty($value)) {
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 100px; height: auto;"></div>', esc_url($value));
}
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildAttributesString(array $attrs): string
{
$attrString = '';
foreach ($attrs as $key => $value) {
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
}
return $attrString;
}
private function buildFormScripts(string $componentId): string
{
return <<<SCRIPT
<script>
(function($) {
'use strict';
$(document).ready(function() {
// Conditional logic
$('[data-conditional-field]').each(function() {
const field = $(this);
const targetFieldName = field.data('conditional-field');
const targetValue = field.data('conditional-value');
const targetField = $('[name*="[' + targetFieldName + ']"]');
function updateVisibility() {
let currentValue;
if (targetField.is(':checkbox')) {
currentValue = targetField.is(':checked') ? 'true' : 'false';
} else {
currentValue = targetField.val();
}
if (currentValue === targetValue) {
field.closest('.roi-form-field').show();
} else {
field.closest('.roi-form-field').hide();
}
}
targetField.on('change', updateVisibility);
updateVisibility();
});
// Media upload
$('.roi-media-upload-btn').on('click', function(e) {
e.preventDefault();
const button = $(this);
const targetId = button.data('target');
const targetField = $('#' + targetId);
const mediaUploader = wp.media({
title: 'Seleccionar imagen',
button: { text: 'Usar esta imagen' },
multiple: false
});
mediaUploader.on('select', function() {
const attachment = mediaUploader.state().get('selection').first().toJSON();
targetField.val(attachment.url);
const preview = targetField.closest('.roi-form-field-media').find('img');
if (preview.length) {
preview.attr('src', attachment.url);
} else {
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 100px; height: auto;"></div>');
}
});
mediaUploader.open();
});
// Color picker sync
$('.form-control-color').on('change', function() {
$(this).next('input[type="text"]').val($(this).val());
});
// Auto-update preview
$('.roi-form-field input, .roi-form-field select, .roi-form-field textarea').on('change keyup', function() {
updatePreview();
});
function updatePreview() {
// Aquí iría la lógica para actualizar la vista previa en tiempo real
console.log('Preview updated');
}
});
})(jQuery);
</script>
SCRIPT;
}
public function getComponentId(): string
{
return $this->componentId;
}
}

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace ROITheme\TopNotificationBar\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
final class TopNotificationBarRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
// Verificar si el componente está habilitado
if (!$this->isEnabled($data)) {
return '';
}
// Verificar si debe mostrarse en la página actual
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
// Verificar si el usuario ha cerrado la barra (si es dismissible)
if ($this->isDismissed($data)) {
return '';
}
$classes = $this->buildClasses($data);
$styles = $this->buildInlineStyles($data);
$content = $this->buildContent($data);
$html = sprintf(
'<div class="%s"%s%s>%s</div>',
esc_attr($classes),
$styles ? ' style="' . esc_attr($styles) . '"' : '',
$this->buildDataAttributes($data),
$content
);
// Agregar script si es dismissible
if ($this->isDismissible($data)) {
$html .= $this->buildDismissScript($data);
}
// Agregar estilos de animación si está habilitada
if ($this->isAnimationEnabled($data)) {
$html .= $this->buildAnimationStyles($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'] ?? 'all';
switch ($showOn) {
case 'all':
return true;
case 'home':
return is_front_page();
case 'posts':
return is_single();
case 'pages':
return is_page();
case 'custom':
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
$allowedIds = array_map('trim', explode(',', $pageIds));
return in_array((string)get_the_ID(), $allowedIds, true);
default:
return true;
}
}
private function isDismissed(array $data): bool
{
if (!$this->isDismissible($data)) {
return false;
}
$cookieName = 'roi_notification_bar_dismissed';
return isset($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] === '1';
}
private function isDismissible(array $data): bool
{
return isset($data['visibility']['is_dismissible']) &&
$data['visibility']['is_dismissible'] === true;
}
private function isAnimationEnabled(array $data): bool
{
return isset($data['styles']['animation_enabled']) &&
$data['styles']['animation_enabled'] === true;
}
private function buildClasses(array $data): string
{
$classes = ['top-notification-bar'];
// Ocultar en móvil si está configurado
if (isset($data['visibility']['hide_on_mobile']) && $data['visibility']['hide_on_mobile']) {
$classes[] = 'd-none';
$classes[] = 'd-md-block';
}
// Agregar clase de animación
if ($this->isAnimationEnabled($data)) {
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
$classes[] = 'roi-animated';
$classes[] = 'roi-' . $animationType;
}
return implode(' ', $classes);
}
private function buildInlineStyles(array $data): string
{
$styles = [];
// Color de fondo
if (!empty($data['styles']['background_color'])) {
$styles[] = 'background-color: ' . $data['styles']['background_color'];
}
// Color de texto
if (!empty($data['styles']['text_color'])) {
$styles[] = 'color: ' . $data['styles']['text_color'];
}
// Tamaño de fuente
$fontSize = $data['styles']['font_size'] ?? 'small';
$fontSizes = [
'extra-small' => '0.75rem',
'small' => '0.875rem',
'normal' => '1rem',
'large' => '1.125rem'
];
if (isset($fontSizes[$fontSize])) {
$styles[] = 'font-size: ' . $fontSizes[$fontSize];
}
// Padding vertical
$padding = $data['styles']['padding_vertical'] ?? 'normal';
$paddings = [
'compact' => '0.5rem 0',
'normal' => '0.75rem 0',
'spacious' => '1rem 0'
];
if (isset($paddings[$padding])) {
$styles[] = 'padding: ' . $paddings[$padding];
}
return implode('; ', $styles);
}
private function buildContent(array $data): string
{
$alignment = $data['styles']['text_alignment'] ?? 'center';
$alignmentClasses = [
'left' => 'justify-content-start',
'center' => 'justify-content-center',
'right' => 'justify-content-end'
];
$alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center';
$html = '<div class="container">';
$html .= sprintf('<div class="d-flex align-items-center %s">', esc_attr($alignmentClass));
// Ícono
$html .= $this->buildIcon($data);
// Texto del anuncio
$html .= $this->buildAnnouncementText($data);
// Enlace
$html .= $this->buildLink($data);
// Botón de cerrar (si es dismissible)
if ($this->isDismissible($data)) {
$html .= $this->buildDismissButton();
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildIcon(array $data): string
{
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
switch ($iconType) {
case 'bootstrap':
$iconClass = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
// Asegurar que tenga el prefijo 'bi'
if (strpos($iconClass, 'bi-') !== 0) {
$iconClass = 'bi-' . $iconClass;
}
return sprintf('<i class="bi %s me-2"></i>', esc_attr($iconClass));
case 'custom':
$iconUrl = $data['content']['custom_icon_url'] ?? '';
if (!empty($iconUrl)) {
return sprintf(
'<img src="%s" alt="Icon" class="me-2" style="width: 24px; height: 24px;">',
esc_url($iconUrl)
);
}
return '';
case 'none':
default:
return '';
}
}
private function buildAnnouncementText(array $data): string
{
$label = $data['content']['announcement_label'] ?? '';
$text = $data['content']['announcement_text'] ?? '';
if (empty($text)) {
return '';
}
$html = '<span>';
if (!empty($label)) {
$html .= sprintf('<strong>%s</strong> ', esc_html($label));
}
$html .= esc_html($text);
$html .= '</span>';
return $html;
}
private function buildLink(array $data): string
{
$linkEnabled = $data['content']['link_enabled'] ?? true;
if (!$linkEnabled) {
return '';
}
$linkText = $data['content']['link_text'] ?? '';
$linkUrl = $data['content']['link_url'] ?? '#';
$linkTarget = $data['content']['link_target'] ?? '_self';
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
if (empty($linkText)) {
return '';
}
return sprintf(
'<a href="%s" target="%s" class="ms-2 text-decoration-underline" style="color: %s;">%s</a>',
esc_url($linkUrl),
esc_attr($linkTarget),
esc_attr($linkColor),
esc_html($linkText)
);
}
private function buildDismissButton(): string
{
return '<button type="button" class="btn-close btn-close-white ms-3" aria-label="Cerrar" onclick="roiDismissNotificationBar()"></button>';
}
private function buildDataAttributes(array $data): string
{
if (!$this->isDismissible($data)) {
return '';
}
$days = $data['visibility']['dismissible_cookie_days'] ?? 7;
return sprintf(' data-dismissible-days="%d"', (int)$days);
}
private function buildDismissScript(array $data): string
{
$days = $data['visibility']['dismissible_cookie_days'] ?? 7;
return <<<SCRIPT
<script>
function roiDismissNotificationBar() {
const bar = document.querySelector('.top-notification-bar');
if (bar) {
bar.style.display = 'none';
}
// Establecer cookie
const days = {$days};
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>
SCRIPT;
}
private function buildAnimationStyles(array $data): string
{
$animationType = $data['styles']['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 <<<STYLE
<style>
{$anim['keyframes']}
.top-notification-bar.roi-animated.roi-{$animationType} {
animation: {$anim['animation']};
}
</style>
STYLE;
}
public function supports(string $componentType): bool
{
return $componentType === 'top_notification_bar';
}
}