diff --git a/schemas/components/contact-form-section.json b/schemas/components/contact-form-section.json
new file mode 100644
index 00000000..75aedf34
--- /dev/null
+++ b/schemas/components/contact-form-section.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/contact-modal.json b/schemas/components/contact-modal.json
new file mode 100644
index 00000000..a3ce787f
--- /dev/null
+++ b/schemas/components/contact-modal.json
@@ -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á"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/cta-below-content.json b/schemas/components/cta-below-content.json
new file mode 100644
index 00000000..380571b4
--- /dev/null
+++ b/schemas/components/cta-below-content.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/cta-box-sidebar.json b/schemas/components/cta-box-sidebar.json
new file mode 100644
index 00000000..0ae322fc
--- /dev/null
+++ b/schemas/components/cta-box-sidebar.json
@@ -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)"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/footer.json b/schemas/components/footer.json
new file mode 100644
index 00000000..3bcf1dd7
--- /dev/null
+++ b/schemas/components/footer.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/hero-section.json b/schemas/components/hero-section.json
new file mode 100644
index 00000000..c9b8da31
--- /dev/null
+++ b/schemas/components/hero-section.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/navbar.json b/schemas/components/navbar.json
new file mode 100644
index 00000000..4a18d297
--- /dev/null
+++ b/schemas/components/navbar.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/related-posts.json b/schemas/components/related-posts.json
new file mode 100644
index 00000000..8c715a3a
--- /dev/null
+++ b/schemas/components/related-posts.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/share-buttons.json b/schemas/components/share-buttons.json
new file mode 100644
index 00000000..12f8a2bb
--- /dev/null
+++ b/schemas/components/share-buttons.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/table-of-contents.json b/schemas/components/table-of-contents.json
new file mode 100644
index 00000000..622891fa
--- /dev/null
+++ b/schemas/components/table-of-contents.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/components/top_notification_bar.json b/schemas/components/top_notification_bar.json
new file mode 100644
index 00000000..2322fa89
--- /dev/null
+++ b/schemas/components/top_notification_bar.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/src/CTABelowContent/Infrastructure/Presentation/Admin/CTABelowContentFormBuilder.php b/src/CTABelowContent/Infrastructure/Presentation/Admin/CTABelowContentFormBuilder.php
new file mode 100644
index 00000000..e69b4666
--- /dev/null
+++ b/src/CTABelowContent/Infrastructure/Presentation/Admin/CTABelowContentFormBuilder.php
@@ -0,0 +1,259 @@
+componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ ob_start();
+ ?>
+
+
+
+ Visibilidad
+
+
+ Contenido
+
+
+ Botón
+
+
+ Estilos
+
+
+ Avanzado
+
+
+
+
buildVisibilityTab(); ?>
+
buildContentTab(); ?>
+
buildButtonTab(); ?>
+
buildStylesTab(); ?>
+
buildAdvancedTab(); ?>
+
+
+ data['visibility']['is_enabled'] ?? true;
+ $layout = $this->data['visibility']['layout'] ?? 'two-column';
+
+ ob_start();
+ ?>
+
+
+ Layout del CTA
+
+ >Dos columnas (texto izquierda, botón derecha)
+ >Centrado
+ >Apilado
+
+ Distribución del contenido en el CTA
+
+ 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();
+ ?>
+
+ Título
+
+ Título principal del CTA (máximo 200 caracteres)
+
+
+ Subtítulo
+
+ Texto descriptivo (máximo 500 caracteres)
+
+ 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();
+ ?>
+
+ Texto del botón
+
+
+
+ URL del botón
+
+ URL de destino al hacer clic en el botón
+
+
+ Abrir enlace en
+
+ >Misma ventana
+ >Nueva ventana
+
+
+
+ Color del botón
+
+ >Blanco
+ >Negro
+ >Azul
+ >Verde
+ >Rojo
+ >Amarillo
+
+
+
+ Tamaño del botón
+
+ >Pequeño
+ >Mediano
+ >Grande
+
+
+
+
+ Clase del icono (Bootstrap Icons)
+
+ Ejemplo: bi bi-arrow-right, bi bi-chevron-right
+
+ 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();
+ ?>
+
+
+
+ Ángulo del gradiente
+
+ °
+ Dirección del gradiente en grados (0-360)
+
+
+
+ Clases CSS del contenedor
+
+ Clases CSS adicionales para el contenedor principal
+
+
+
Vista previa del gradiente:
+
+ Vista previa del gradiente con el color de texto seleccionado
+
+
+ data['advanced']['animation_enabled'] ?? false;
+ $animationType = $this->data['advanced']['animation_type'] ?? 'fade-in';
+ $animationDuration = $this->data['advanced']['animation_duration'] ?? 500;
+
+ ob_start();
+ ?>
+
+
+ Tipo de animación
+
+ >Fade In
+ >Slide Up
+ >Scale
+
+
+
+ Duración de animación (ms)
+
+
+
+
Notas:
+
+ Layout: Elige cómo se distribuyen los elementos (dos columnas, centrado o apilado)
+ Gradiente: Personaliza los colores y ángulo del gradiente de fondo
+ Botón: Configura el texto, URL, color y tamaño del botón de acción
+ Animación: Activa animaciones de entrada para hacer el CTA más llamativo
+
+
+ componentId;
+ }
+}
diff --git a/src/CTABelowContent/Infrastructure/Presentation/Public/CTABelowContentRenderer.php b/src/CTABelowContent/Infrastructure/Presentation/Public/CTABelowContentRenderer.php
new file mode 100644
index 00000000..ce187dfb
--- /dev/null
+++ b/src/CTABelowContent/Infrastructure/Presentation/Public/CTABelowContentRenderer.php
@@ -0,0 +1,247 @@
+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(
+ '',
+ esc_attr($containerClasses),
+ esc_attr($gradient),
+ $animationAttrs
+ );
+
+ $html .= $this->renderLayout($data, $layout);
+ $html .= '
';
+
+ 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(
+ '',
+ $this->renderTitle($data),
+ $this->renderSubtitle($data, 'mb-md-0'),
+ $this->renderButton($data)
+ );
+ }
+
+ private function renderCenteredLayout(array $data): string
+ {
+ return sprintf(
+ '',
+ $this->renderTitle($data),
+ $this->renderSubtitle($data, 'mb-3'),
+ $this->renderButton($data)
+ );
+ }
+
+ private function renderStackedLayout(array $data): string
+ {
+ return sprintf(
+ '',
+ $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(
+ '%s ',
+ 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(
+ '%s
',
+ 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(' ', esc_attr($iconClass));
+ }
+
+ return sprintf(
+ '%s%s ',
+ 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;
+ }
+
+ public function supports(string $componentType): bool
+ {
+ return $componentType === 'cta-below-content';
+ }
+}
diff --git a/src/CTABoxSidebar/Infrastructure/Presentation/Admin/CTABoxSidebarFormBuilder.php b/src/CTABoxSidebar/Infrastructure/Presentation/Admin/CTABoxSidebarFormBuilder.php
new file mode 100644
index 00000000..0eafd01e
--- /dev/null
+++ b/src/CTABoxSidebar/Infrastructure/Presentation/Admin/CTABoxSidebarFormBuilder.php
@@ -0,0 +1,298 @@
+componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ ob_start();
+ ?>
+
+
+
+ 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();
+ ?>
+
+ Título
+
+ Título principal del CTA (máx. 100 caracteres)
+
+
+ Descripción
+
+ Texto descriptivo (máx. 200 caracteres)
+
+ 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();
+ ?>
+
+ Texto del botón
+
+ Texto que aparece en el botón (máx. 50 caracteres)
+
+
+
+ data['config']['height'] ?? '250px';
+ $showOnMobile = $this->data['config']['show_on_mobile'] ?? true;
+ $customCssClass = $this->data['config']['custom_css_class'] ?? '';
+
+ ob_start();
+ ?>
+
+ Altura del CTA box
+
+ Valor CSS válido (ej: 250px, 20rem)
+
+
+
+ Clase CSS personalizada
+
+ Opcional: clase CSS adicional
+
+ 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();
+ ?>
+
+
+
+ Color de fondo
+
+
+
+
+
+
+ componentId;
+ }
+}
diff --git a/src/CTABoxSidebar/Infrastructure/Presentation/Public/CTABoxSidebarRenderer.php b/src/CTABoxSidebar/Infrastructure/Presentation/Public/CTABoxSidebarRenderer.php
new file mode 100644
index 00000000..93c56e80
--- /dev/null
+++ b/src/CTABoxSidebar/Infrastructure/Presentation/Public/CTABoxSidebarRenderer.php
@@ -0,0 +1,157 @@
+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();
+ ?>
+
+
+
+
+
+
+ $data Data to validate
+ * @param array $rules Validation rules
+ * @return bool
+ */
+ public function validate(array $data, array $rules): bool;
+
+ /**
+ * Get validation errors
+ *
+ * @return array
+ */
+ public function getErrors(): array;
+}
diff --git a/src/Component/Application/DTO/.gitkeep b/src/Component/Application/DTO/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Application/README.md b/src/Component/Application/README.md
new file mode 100644
index 00000000..9f33888d
--- /dev/null
+++ b/src/Component/Application/README.md
@@ -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/`
diff --git a/src/Component/Application/UseCases/DeleteComponent/DeleteComponentRequest.php b/src/Component/Application/UseCases/DeleteComponent/DeleteComponentRequest.php
new file mode 100644
index 00000000..60afd372
--- /dev/null
+++ b/src/Component/Application/UseCases/DeleteComponent/DeleteComponentRequest.php
@@ -0,0 +1,21 @@
+componentName;
+ }
+}
diff --git a/src/Component/Application/UseCases/DeleteComponent/DeleteComponentResponse.php b/src/Component/Application/UseCases/DeleteComponent/DeleteComponentResponse.php
new file mode 100644
index 00000000..d7889662
--- /dev/null
+++ b/src/Component/Application/UseCases/DeleteComponent/DeleteComponentResponse.php
@@ -0,0 +1,52 @@
+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
+ ];
+ }
+}
diff --git a/src/Component/Application/UseCases/DeleteComponent/DeleteComponentUseCase.php b/src/Component/Application/UseCases/DeleteComponent/DeleteComponentUseCase.php
new file mode 100644
index 00000000..39214135
--- /dev/null
+++ b/src/Component/Application/UseCases/DeleteComponent/DeleteComponentUseCase.php
@@ -0,0 +1,68 @@
+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()
+ );
+ }
+ }
+}
diff --git a/src/Component/Application/UseCases/GetComponent/GetComponentRequest.php b/src/Component/Application/UseCases/GetComponent/GetComponentRequest.php
new file mode 100644
index 00000000..e0017a32
--- /dev/null
+++ b/src/Component/Application/UseCases/GetComponent/GetComponentRequest.php
@@ -0,0 +1,47 @@
+componentName;
+ }
+
+ /**
+ * Factory method: Crear desde string
+ *
+ * @param string $componentName
+ * @return self
+ */
+ public static function fromString(string $componentName): self
+ {
+ return new self($componentName);
+ }
+}
diff --git a/src/Component/Application/UseCases/GetComponent/GetComponentResponse.php b/src/Component/Application/UseCases/GetComponent/GetComponentResponse.php
new file mode 100644
index 00000000..fbb549e3
--- /dev/null
+++ b/src/Component/Application/UseCases/GetComponent/GetComponentResponse.php
@@ -0,0 +1,109 @@
+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
+ ];
+ }
+}
diff --git a/src/Component/Application/UseCases/GetComponent/GetComponentUseCase.php b/src/Component/Application/UseCases/GetComponent/GetComponentUseCase.php
new file mode 100644
index 00000000..9b0e3650
--- /dev/null
+++ b/src/Component/Application/UseCases/GetComponent/GetComponentUseCase.php
@@ -0,0 +1,105 @@
+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}";
+ }
+}
diff --git a/src/Component/Application/UseCases/SaveComponent/SaveComponentRequest.php b/src/Component/Application/UseCases/SaveComponent/SaveComponentRequest.php
new file mode 100644
index 00000000..9ac577d7
--- /dev/null
+++ b/src/Component/Application/UseCases/SaveComponent/SaveComponentRequest.php
@@ -0,0 +1,73 @@
+ ['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'] ?? []
+ );
+ }
+}
diff --git a/src/Component/Application/UseCases/SaveComponent/SaveComponentResponse.php b/src/Component/Application/UseCases/SaveComponent/SaveComponentResponse.php
new file mode 100644
index 00000000..8cf370f1
--- /dev/null
+++ b/src/Component/Application/UseCases/SaveComponent/SaveComponentResponse.php
@@ -0,0 +1,121 @@
+ '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
+ ];
+ }
+}
diff --git a/src/Component/Application/UseCases/SaveComponent/SaveComponentUseCase.php b/src/Component/Application/UseCases/SaveComponent/SaveComponentUseCase.php
new file mode 100644
index 00000000..2d074837
--- /dev/null
+++ b/src/Component/Application/UseCases/SaveComponent/SaveComponentUseCase.php
@@ -0,0 +1,143 @@
+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
+ );
+ }
+}
diff --git a/src/Component/Application/UseCases/SyncSchema/.gitkeep b/src/Component/Application/UseCases/SyncSchema/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Application/UseCases/SyncSchema/SyncSchemaRequest.php b/src/Component/Application/UseCases/SyncSchema/SyncSchemaRequest.php
new file mode 100644
index 00000000..493b8a94
--- /dev/null
+++ b/src/Component/Application/UseCases/SyncSchema/SyncSchemaRequest.php
@@ -0,0 +1,36 @@
+schemaFilePath;
+ }
+}
diff --git a/src/Component/Application/UseCases/SyncSchema/SyncSchemaResponse.php b/src/Component/Application/UseCases/SyncSchema/SyncSchemaResponse.php
new file mode 100644
index 00000000..373d617f
--- /dev/null
+++ b/src/Component/Application/UseCases/SyncSchema/SyncSchemaResponse.php
@@ -0,0 +1,111 @@
+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
+ );
+ }
+}
diff --git a/src/Component/Application/UseCases/SyncSchema/SyncSchemaUseCase.php b/src/Component/Application/UseCases/SyncSchema/SyncSchemaUseCase.php
new file mode 100644
index 00000000..b6f6bd97
--- /dev/null
+++ b/src/Component/Application/UseCases/SyncSchema/SyncSchemaUseCase.php
@@ -0,0 +1,140 @@
+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;
+ }
+}
diff --git a/src/Component/Domain/Component.php b/src/Component/Domain/Component.php
new file mode 100644
index 00000000..be26c0f3
--- /dev/null
+++ b/src/Component/Domain/Component.php
@@ -0,0 +1,324 @@
+= 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'
+ );
+ }
+ }
+}
diff --git a/src/Component/Domain/Component.php.backup b/src/Component/Domain/Component.php.backup
new file mode 100644
index 00000000..df62a423
--- /dev/null
+++ b/src/Component/Domain/Component.php.backup
@@ -0,0 +1,95 @@
+ ['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);
+ }
+}
diff --git a/src/Component/Domain/ComponentRepositoryInterface.php b/src/Component/Domain/ComponentRepositoryInterface.php
new file mode 100644
index 00000000..9624e34f
--- /dev/null
+++ b/src/Component/Domain/ComponentRepositoryInterface.php
@@ -0,0 +1,47 @@
+
+ */
+ public function findAll(): array;
+
+ /**
+ * Delete a component
+ *
+ * @param int $id Component ID
+ * @return bool
+ */
+ public function delete(int $id): bool;
+}
diff --git a/src/Component/Domain/Exceptions/ComponentNotFoundException.php b/src/Component/Domain/Exceptions/ComponentNotFoundException.php
new file mode 100644
index 00000000..3eafee5a
--- /dev/null
+++ b/src/Component/Domain/Exceptions/ComponentNotFoundException.php
@@ -0,0 +1,69 @@
+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)
+ );
+ }
+}
diff --git a/src/Component/Domain/Exceptions/InvalidComponentException.php b/src/Component/Domain/Exceptions/InvalidComponentException.php
new file mode 100644
index 00000000..3d360b0a
--- /dev/null
+++ b/src/Component/Domain/Exceptions/InvalidComponentException.php
@@ -0,0 +1,98 @@
+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;
+}
diff --git a/src/Component/Domain/RendererInterface.php b/src/Component/Domain/RendererInterface.php
new file mode 100644
index 00000000..abf44763
--- /dev/null
+++ b/src/Component/Domain/RendererInterface.php
@@ -0,0 +1,53 @@
+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;
+}
diff --git a/src/Component/Domain/ValueObjects/ComponentConfiguration.php b/src/Component/Domain/ValueObjects/ComponentConfiguration.php
new file mode 100644
index 00000000..81a3a8f5
--- /dev/null
+++ b/src/Component/Domain/ValueObjects/ComponentConfiguration.php
@@ -0,0 +1,349 @@
+ [
+ * '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' => []
+ ]);
+ }
+}
diff --git a/src/Component/Domain/ValueObjects/ComponentContent.php b/src/Component/Domain/ValueObjects/ComponentContent.php
new file mode 100644
index 00000000..ffe84cf2
--- /dev/null
+++ b/src/Component/Domain/ValueObjects/ComponentContent.php
@@ -0,0 +1,272 @@
+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;
+ }
+}
diff --git a/src/Component/Domain/ValueObjects/ComponentName.php b/src/Component/Domain/ValueObjects/ComponentName.php
new file mode 100644
index 00000000..fbb71c90
--- /dev/null
+++ b/src/Component/Domain/ValueObjects/ComponentName.php
@@ -0,0 +1,169 @@
+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);
+ }
+}
diff --git a/src/Component/Domain/ValueObjects/ComponentVisibility.php b/src/Component/Domain/ValueObjects/ComponentVisibility.php
new file mode 100644
index 00000000..ab8be987
--- /dev/null
+++ b/src/Component/Domain/ValueObjects/ComponentVisibility.php
@@ -0,0 +1,266 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/API/WordPress/AjaxController.php b/src/Component/Infrastructure/API/WordPress/AjaxController.php
new file mode 100644
index 00000000..9847c2f8
--- /dev/null
+++ b/src/Component/Infrastructure/API/WordPress/AjaxController.php
@@ -0,0 +1,215 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/API/WordPress/MigrationCommand.php b/src/Component/Infrastructure/API/WordPress/MigrationCommand.php
new file mode 100644
index 00000000..6a0a7698
--- /dev/null
+++ b/src/Component/Infrastructure/API/WordPress/MigrationCommand.php
@@ -0,0 +1,189 @@
+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);
+}
diff --git a/src/Component/Infrastructure/Adapters/LegacyDBManagerAdapter.php b/src/Component/Infrastructure/Adapters/LegacyDBManagerAdapter.php
new file mode 100644
index 00000000..811781d5
--- /dev/null
+++ b/src/Component/Infrastructure/Adapters/LegacyDBManagerAdapter.php
@@ -0,0 +1,320 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/Adapters/README.md b/src/Component/Infrastructure/Adapters/README.md
new file mode 100644
index 00000000..e3868527
--- /dev/null
+++ b/src/Component/Infrastructure/Adapters/README.md
@@ -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)
diff --git a/src/Component/Infrastructure/DI/DIContainer.php b/src/Component/Infrastructure/DI/DIContainer.php
new file mode 100644
index 00000000..aaeb5568
--- /dev/null
+++ b/src/Component/Infrastructure/DI/DIContainer.php
@@ -0,0 +1,130 @@
+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 = [];
+ }
+}
diff --git a/src/Component/Infrastructure/Facades/.gitkeep b/src/Component/Infrastructure/Facades/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Infrastructure/Facades/ComponentManager.php b/src/Component/Infrastructure/Facades/ComponentManager.php
new file mode 100644
index 00000000..e72dbe15
--- /dev/null
+++ b/src/Component/Infrastructure/Facades/ComponentManager.php
@@ -0,0 +1,115 @@
+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();
+ }
+}
diff --git a/src/Component/Infrastructure/Logging/DeprecationLogger.php b/src/Component/Infrastructure/Logging/DeprecationLogger.php
new file mode 100644
index 00000000..e03ce6ff
--- /dev/null
+++ b/src/Component/Infrastructure/Logging/DeprecationLogger.php
@@ -0,0 +1,350 @@
+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');
+ }
+}
diff --git a/src/Component/Infrastructure/Persistence/WordPress/DatabaseMigrator.php b/src/Component/Infrastructure/Persistence/WordPress/DatabaseMigrator.php
new file mode 100644
index 00000000..8f1e5248
--- /dev/null
+++ b/src/Component/Infrastructure/Persistence/WordPress/DatabaseMigrator.php
@@ -0,0 +1,515 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/Persistence/WordPress/Repositories/.gitkeep b/src/Component/Infrastructure/Persistence/WordPress/Repositories/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Infrastructure/Persistence/WordPress/WordPressComponentRepository.php b/src/Component/Infrastructure/Persistence/WordPress/WordPressComponentRepository.php
new file mode 100644
index 00000000..7a0f5b0f
--- /dev/null
+++ b/src/Component/Infrastructure/Persistence/WordPress/WordPressComponentRepository.php
@@ -0,0 +1,247 @@
+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']
+ );
+ }
+}
diff --git a/src/Component/Infrastructure/Persistence/WordPress/WordPressDefaultsRepository.php b/src/Component/Infrastructure/Persistence/WordPress/WordPressDefaultsRepository.php
new file mode 100644
index 00000000..4810b379
--- /dev/null
+++ b/src/Component/Infrastructure/Persistence/WordPress/WordPressDefaultsRepository.php
@@ -0,0 +1,202 @@
+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 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);
+ }
+}
diff --git a/src/Component/Infrastructure/Presentation/Admin/FormBuilders/.gitkeep b/src/Component/Infrastructure/Presentation/Admin/FormBuilders/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Infrastructure/Presentation/Public/Renderers/.gitkeep b/src/Component/Infrastructure/Presentation/Public/Renderers/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Infrastructure/README.md b/src/Component/Infrastructure/README.md
new file mode 100644
index 00000000..5e9b029c
--- /dev/null
+++ b/src/Component/Infrastructure/README.md
@@ -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`
diff --git a/src/Component/Infrastructure/Services/CleanupService.php b/src/Component/Infrastructure/Services/CleanupService.php
new file mode 100644
index 00000000..46e4eb0d
--- /dev/null
+++ b/src/Component/Infrastructure/Services/CleanupService.php
@@ -0,0 +1,51 @@
+ 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];
+ }
+}
diff --git a/src/Component/Infrastructure/Services/SchemaSyncService.php b/src/Component/Infrastructure/Services/SchemaSyncService.php
new file mode 100644
index 00000000..8b5d3e17
--- /dev/null
+++ b/src/Component/Infrastructure/Services/SchemaSyncService.php
@@ -0,0 +1,152 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/Services/WordPressCacheService.php b/src/Component/Infrastructure/Services/WordPressCacheService.php
new file mode 100644
index 00000000..790d5f89
--- /dev/null
+++ b/src/Component/Infrastructure/Services/WordPressCacheService.php
@@ -0,0 +1,124 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/Services/WordPressValidationService.php b/src/Component/Infrastructure/Services/WordPressValidationService.php
new file mode 100644
index 00000000..eadde9f9
--- /dev/null
+++ b/src/Component/Infrastructure/Services/WordPressValidationService.php
@@ -0,0 +1,172 @@
+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;
+ }
+}
diff --git a/src/Component/Infrastructure/UI/Assets/.gitkeep b/src/Component/Infrastructure/UI/Assets/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Component/Infrastructure/UI/Views/.gitkeep b/src/Component/Infrastructure/UI/Views/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/src/ContactFormSection/Infrastructure/Presentation/Admin/ContactFormSectionFormBuilder.php b/src/ContactFormSection/Infrastructure/Presentation/Admin/ContactFormSectionFormBuilder.php
new file mode 100644
index 00000000..c1d3cae8
--- /dev/null
+++ b/src/ContactFormSection/Infrastructure/Presentation/Admin/ContactFormSectionFormBuilder.php
@@ -0,0 +1,212 @@
+componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ ob_start();
+ ?>
+
+ data['section']['show_section'] ?? true;
+ $sectionTitle = $this->data['section']['section_title'] ?? '¿Tienes alguna pregunta?';
+ $sectionSubtitle = $this->data['section']['section_subtitle'] ?? '';
+
+ ob_start();
+ ?>
+
+
+ Título de la sección
+
+
+
+ Subtítulo
+
+
+ data['contact_info'] ?? [];
+
+ ob_start();
+ ?>
+
+
+
+
+
+ data['form'] ?? [];
+
+ ob_start();
+ ?>
+
+ Texto del botón de envío
+
+
+
+ Ícono del botón
+
+ Clase de Bootstrap Icons
+
+
+ Mensaje de éxito
+
+
+
+ Mensaje de error
+
+
+
+ Email de destino
+
+ Deja vacío para usar el email del administrador
+
+ data['styles'] ?? [];
+
+ ob_start();
+ ?>
+
+ Clase de fondo
+
+ Clase de Bootstrap (ej: bg-light, bg-secondary bg-opacity-25)
+
+
+ componentId;
+ }
+}
diff --git a/src/ContactFormSection/Infrastructure/Presentation/Public/ContactFormSectionRenderer.php b/src/ContactFormSection/Infrastructure/Presentation/Public/ContactFormSectionRenderer.php
new file mode 100644
index 00000000..2d8f2734
--- /dev/null
+++ b/src/ContactFormSection/Infrastructure/Presentation/Public/ContactFormSectionRenderer.php
@@ -0,0 +1,238 @@
+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();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ ob_start();
+ ?>
+
+ 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();
+ ?>
+
+ Título del modal
+
+ Título que aparece en el encabezado del modal
+
+
+
+ Descripción
+
+ Texto descriptivo debajo del título
+
+
+
+
+ Botón de Envío
+
+
+ Texto del botón
+
+ Texto que aparece en el botón de envío
+
+
+
+ data['form_fields'] ?? [];
+
+ ob_start();
+ ?>
+
+ Configure las etiquetas, placeholders y si cada campo es obligatorio o no.
+
+
+ buildFieldConfig('fullName', 'Nombre Completo', $formFields['fullName'] ?? []); ?>
+ buildFieldConfig('company', 'Empresa', $formFields['company'] ?? []); ?>
+ buildFieldConfig('whatsapp', 'WhatsApp', $formFields['whatsapp'] ?? []); ?>
+ buildFieldConfig('email', 'Correo Electrónico', $formFields['email'] ?? []); ?>
+ buildFieldConfig('comments', 'Comentarios', $formFields['comments'] ?? [], true); ?>
+
+
+ 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();
+ ?>
+
+ Configure los mensajes que se mostrarán al usuario según el resultado del envío del formulario.
+
+
+
+ Mensaje de éxito
+
+ Mensaje que se muestra cuando el formulario se envía correctamente
+
+
+
+ Mensaje de error
+
+ Mensaje que se muestra cuando ocurre un error al enviar
+
+
+
+ Mensaje de validación
+
+ Mensaje que se muestra cuando faltan campos obligatorios
+
+ 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();
+ ?>
+
+ Configuración técnica del modal y envío de emails.
+
+
+ Configuración de Email
+
+
+ Email de destino
+
+ Email donde se recibirán los mensajes de contacto (vacío = admin email)
+
+
+
+ Asunto del email
+
+ Asunto del email que se enviará
+
+
+
+
+ Configuración Técnica
+
+
+
+ Advertencia: Los siguientes campos no deben modificarse a menos que sepas lo que estás haciendo.
+
+
+
+ ID del modal
+
+ ID HTML del modal (no modificar)
+
+
+
+ ID del formulario
+
+ ID HTML del formulario (no modificar)
+
+
+
+ Acción AJAX
+
+ Nombre de la acción AJAX de WordPress (no modificar)
+
+ componentId;
+ }
+}
diff --git a/src/ContactModal/Infrastructure/Presentation/Public/ContactModalRenderer.php b/src/ContactModal/Infrastructure/Presentation/Public/ContactModalRenderer.php
new file mode 100644
index 00000000..4ff84c34
--- /dev/null
+++ b/src/ContactModal/Infrastructure/Presentation/Public/ContactModalRenderer.php
@@ -0,0 +1,350 @@
+getData();
+ $componentId = $component->getId();
+ $modalId = $data['settings']['modal_id'] ?? 'contactModal';
+ $formId = $data['settings']['form_id'] ?? 'modalContactForm';
+
+ ob_start();
+ ?>
+
+
+
+ renderHeader($data, $modalId); ?>
+ renderBody($data, $formId, $componentId); ?>
+
+
+
+
+ renderScript($data, $formId, $modalId, $componentId); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ renderFormFields($data); ?>
+ renderSubmitButton($data); ?>
+
+
+
+
+
+ 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();
+ ?>
+
+
+
+
+ *
+
+
+
required
+ >
+
+
Por favor completa este campo
+
+
+
+
+
+
+
+ *
+
+
+
required
+ >
+
+
Por favor completa este campo
+
+
+
+
+
+
+
+ *
+
+
+
required
+ >
+
+
Por favor ingresa un email válido
+
+
+
+
+
+
+
+ *
+
+
+
required
+ >
+
+
Por favor completa este campo
+
+
+
+
+
+
+
+
+
+
+
+ componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ ob_start();
+ ?>
+
+
+
+
+
+
+ buildWidgetColumn('widget_1', 'Widget 1'); ?>
+
+
+ buildWidgetColumn('widget_2', 'Widget 2'); ?>
+
+
+ buildWidgetColumn('widget_3', 'Widget 3'); ?>
+
+
+ data[$widgetKey] ?? [];
+ $enabled = $widgetData['enabled'] ?? true;
+ $title = $widgetData['title'] ?? '';
+ $links = $widgetData['links'] ?? [];
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+ Título del widget
+
+
+
+
+
+
+ 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();
+ ?>
+
+
+
+ Título
+
+ Título de la sección de newsletter
+
+
+
+ Descripción
+
+ Texto descriptivo debajo del título
+
+
+
+ Placeholder del campo
+
+ Texto placeholder del campo de email
+
+
+
+ Texto del botón
+
+ Texto del botón de suscripción
+
+
+
+
+ Nota: Para configurar el servicio de email de newsletter, ve a la sección de configuración general del tema.
+
+ data['copyright'] ?? [];
+ $text = $copyright['text'] ?? 'ROI Theme. Todos los derechos reservados.';
+ $yearAuto = $copyright['year_auto'] ?? true;
+
+ ob_start();
+ ?>
+
+ Texto de copyright
+
+ Texto que aparece en el footer
+
+
+
+
+
+ Vista previa:
+
+ ©
+
+
+
+ data['social_links'] ?? [];
+ $twitter = $social['twitter'] ?? '';
+ $instagram = $social['instagram'] ?? '';
+ $facebook = $social['facebook'] ?? '';
+ $linkedin = $social['linkedin'] ?? '';
+
+ ob_start();
+ ?>
+
+ Configure los enlaces a sus redes sociales. Deje vacío para ocultar la red social correspondiente.
+
+
+
+
+
+ Twitter
+
+
+
+
+
+
+ Instagram
+
+
+
+
+
+
+ Facebook
+
+
+
+
+
+
+ LinkedIn
+
+
+
+
+ data['styles'] ?? [];
+ $backgroundColor = $styles['background_color'] ?? 'bg-dark';
+ $textColor = $styles['text_color'] ?? 'text-white';
+ $linkHoverColor = $styles['link_hover_color'] ?? '#FF8600';
+
+ ob_start();
+ ?>
+
+ Clase de fondo
+
+ Clase de Bootstrap (ej: bg-dark, bg-secondary, bg-primary)
+
+
+
+ Clase de color de texto
+
+ Clase de Bootstrap (ej: text-white, text-light, text-dark)
+
+
+
+ Color de enlaces al hover
+
+ Color que se mostrará cuando se pase el mouse sobre los enlaces
+
+
+
+
+ Sugerencia: Las clases de Bootstrap disponibles incluyen: bg-primary, bg-secondary, bg-success, bg-danger, bg-warning, bg-info, bg-light, bg-dark
+
+ componentId;
+ }
+}
diff --git a/src/Footer/Infrastructure/Presentation/Public/FooterRenderer.php b/src/Footer/Infrastructure/Presentation/Public/FooterRenderer.php
new file mode 100644
index 00000000..a0c6b115
--- /dev/null
+++ b/src/Footer/Infrastructure/Presentation/Public/FooterRenderer.php
@@ -0,0 +1,273 @@
+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();
+ ?>
+
+
+
+
+
+
+
+ renderWidget($data['widget_1'] ?? [], 'col-6 col-md-2 mb-3'); ?>
+ renderWidget($data['widget_2'] ?? [], 'col-6 col-md-2 mb-3'); ?>
+ renderWidget($data['widget_3'] ?? [], 'col-6 col-md-2 mb-3'); ?>
+ renderNewsletter($componentId, $data['newsletter'] ?? []); ?>
+
+
+
+ renderCopyright($data['copyright'] ?? []); ?>
+ renderSocialLinks($data['social_links'] ?? []); ?>
+
+
+
+
+
+
+
+
+
+
+ renderLink($link); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ©
+
+
+
+ '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();
+ ?>
+
+ $icon): ?>
+
+
+
+
+
+
+
+ componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ $data = $this->data;
+ $componentId = $this->componentId;
+
+ $html = '';
+ $html .= $this->buildFormScripts($componentId);
+
+ return $html;
+ }
+
+ private function buildTabsNavigation(): string
+ {
+ return <<
+ Visibilidad
+ Categorías
+ Título
+ Estilos
+
+HTML;
+ }
+
+ private function buildVisibilityTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $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 .= '
';
+ return $html;
+ }
+
+ private function buildCategoriesTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $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 .= '
';
+ return $html;
+ }
+
+ private function buildTitleTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $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 .= '
';
+ return $html;
+ }
+
+ private function buildStylesTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $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 .= '
';
+ 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('', 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('%s
', 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('%s %s
', 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('%s ', esc_attr($fieldId), esc_html($label));
+ $html .= sprintf('', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $attrString);
+ foreach ($options as $optValue => $optLabel) {
+ $selected = ($value === $optValue) ? 'selected' : '';
+ $html .= sprintf('%s ', esc_attr($optValue), $selected, esc_html($optLabel));
+ }
+ $html .= '
';
+ 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('%s
', 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('', 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('';
+ 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;
+ }
+
+ public function getComponentId(): string
+ {
+ return $this->componentId;
+ }
+}
diff --git a/src/HeroSection/Infrastructure/Presentation/Public/HeroSectionRenderer.php b/src/HeroSection/Infrastructure/Presentation/Public/HeroSectionRenderer.php
new file mode 100644
index 00000000..a8a79258
--- /dev/null
+++ b/src/HeroSection/Infrastructure/Presentation/Public/HeroSectionRenderer.php
@@ -0,0 +1,465 @@
+getData();
+
+ if (!$this->isEnabled($data)) {
+ return '';
+ }
+
+ if (!$this->shouldShowOnCurrentPage($data)) {
+ return '';
+ }
+
+ $classes = $this->buildSectionClasses($data);
+ $styles = $this->buildInlineStyles($data);
+
+ $html = sprintf(
+ '',
+ esc_attr($classes),
+ $styles ? ' style="' . esc_attr($styles) . '"' : ''
+ );
+
+ $html .= '
';
+
+ // Categories badges
+ if ($this->shouldShowCategories($data)) {
+ $html .= $this->buildCategoriesBadges($data);
+ }
+
+ // Title
+ $html .= $this->buildTitle($data);
+
+ $html .= '
';
+ $html .= '
';
+
+ // 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('', esc_attr($alignmentClass));
+ $html .= '
';
+
+ foreach ($categories as $category) {
+ $html .= sprintf(
+ '
%s',
+ esc_url($category['url']),
+ esc_attr($icon),
+ esc_html($category['name'])
+ );
+ }
+
+ $html .= '
';
+ $html .= '
';
+
+ 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 = << .container {
+ position: relative;
+ z-index: 1;
+}
+CSS;
+ }
+
+ return <<
+.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}
+
+STYLES;
+ }
+
+ public function supports(string $componentType): bool
+ {
+ return $componentType === 'hero-section';
+ }
+}
diff --git a/src/Navbar/Infrastructure/Presentation/Admin/NavbarFormBuilder.php b/src/Navbar/Infrastructure/Presentation/Admin/NavbarFormBuilder.php
new file mode 100644
index 00000000..d482f48f
--- /dev/null
+++ b/src/Navbar/Infrastructure/Presentation/Admin/NavbarFormBuilder.php
@@ -0,0 +1,665 @@
+componentId = $componentId;
+ $this->data = $data;
+ }
+
+ public function build(): string
+ {
+ $data = $this->data;
+ $componentId = $this->componentId;
+
+ $html = '';
+
+ $html .= $this->buildFormScripts($componentId);
+
+ return $html;
+ }
+
+ private function buildTabsNavigation(): string
+ {
+ return <<
+
+ Visibilidad
+
+
+ Logo
+
+
+ Menú
+
+
+ Botón CTA
+
+
+ Estilos
+
+
+HTML;
+ }
+
+ private function buildVisibilityTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $html .= '
';
+
+ $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 .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildLogoTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $html .= '
';
+
+ $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 .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildMenuTab(array $data, string $componentId): string
+ {
+ $html = '';
+
+ return $html;
+ }
+
+ private function buildCtaButtonTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $html .= '
';
+
+ $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 .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildStylesTab(array $data, string $componentId): string
+ {
+ $html = '';
+ $html .= '
';
+
+ $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 .= '
';
+ $html .= '
';
+
+ 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 = '';
+
+ 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 = '';
+ $html .= sprintf('%s ', esc_attr($fieldId), esc_html($label));
+ $html .= sprintf(
+ ' ',
+ esc_attr($fieldId),
+ esc_attr($componentId),
+ esc_attr($group),
+ esc_attr($name),
+ esc_attr($value),
+ esc_attr($placeholder),
+ $attrString
+ );
+ $html .= '
';
+
+ 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 = '';
+ $html .= sprintf('%s ', esc_attr($fieldId), esc_html($label));
+ $html .= sprintf(
+ '',
+ esc_attr($fieldId),
+ esc_attr($componentId),
+ esc_attr($group),
+ esc_attr($name),
+ $attrString
+ );
+
+ foreach ($options as $optValue => $optLabel) {
+ $selected = ($value === $optValue) ? 'selected' : '';
+ $html .= sprintf('%s ', esc_attr($optValue), $selected, esc_html($optLabel));
+ }
+
+ $html .= ' ';
+ $html .= '
';
+
+ 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 = '';
+ $html .= sprintf('%s ', esc_attr($fieldId), esc_html($label));
+ $html .= sprintf(
+ ' ',
+ esc_attr($fieldId),
+ esc_attr($componentId),
+ esc_attr($group),
+ esc_attr($name),
+ esc_attr($value),
+ $attrString
+ );
+ $html .= '
';
+
+ 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 = '';
+
+ 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 = '';
+
+ 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;
+ }
+
+ public function getComponentId(): string
+ {
+ return $this->componentId;
+ }
+}
diff --git a/src/Navbar/Infrastructure/Presentation/Public/NavbarRenderer.php b/src/Navbar/Infrastructure/Presentation/Public/NavbarRenderer.php
new file mode 100644
index 00000000..63444ee3
--- /dev/null
+++ b/src/Navbar/Infrastructure/Presentation/Public/NavbarRenderer.php
@@ -0,0 +1,507 @@
+getData();
+
+ if (!$this->isEnabled($data)) {
+ return '';
+ }
+
+ $classes = $this->buildNavbarClasses($data);
+ $styles = $this->buildInlineStyles($data);
+ $containerClasses = $this->buildContainerClasses($data);
+
+ $html = sprintf(
+ '',
+ esc_attr($classes),
+ $styles ? ' style="' . esc_attr($styles) . '"' : ''
+ );
+
+ $html .= sprintf('', 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 .= '
';
+
+ // 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 .= '
';
+ $html .= '
';
+ $html .= ' ';
+
+ // 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 = '';
+
+ switch ($logoType) {
+ case 'image':
+ $imageUrl = $data['logo']['logo_image_url'] ?? '';
+ $imageWidth = $data['logo']['logo_image_width'] ?? 150;
+ if (!empty($imageUrl)) {
+ $logoHtml .= sprintf(
+ ' ',
+ 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 .= ' ';
+
+ if ($logoPosition === 'center') {
+ $logoHtml = '' . $logoHtml . '
';
+ }
+
+ return $logoHtml;
+ }
+
+ private function buildMobileToggle(array $data): string
+ {
+ return '
+
+ ';
+ }
+
+ 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' => '',
+ '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(' ', 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 <<
+.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);
+}
+
+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 = '';
+
+ 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