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(); + ?> +
+ +
+
buildVisibilityTab(); ?>
+
buildContentTab(); ?>
+
buildButtonTab(); ?>
+
buildStylesTab(); ?>
+
buildAdvancedTab(); ?>
+
+
+ data['visibility']['is_enabled'] ?? true; + $layout = $this->data['visibility']['layout'] ?? 'two-column'; + + ob_start(); + ?> +
+
+ > + +
+ Muestra u oculta el componente de Call to Action +
+
+ + + 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 principal del CTA (máximo 200 caracteres) +
+
+ + + 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(); + ?> +
+ + +
+
+ + + URL de destino al hacer clic en el botón +
+
+ + +
+
+ + +
+
+ + +
+
+
+ > + +
+
+
+ + + 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(); + ?> +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + ° + Dirección del gradiente en grados (0-360) +
+
+ +
+ + +
+
+
+ + + 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(); + ?> +
+
+ > + +
+ Activa animaciones de entrada para hacer el CTA más llamativo +
+
+ + +
+
+ + +
+
+
Notas:
+ +
+ 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( + '
+
+ %s + %s +
+
+ %s +
+
', + $this->renderTitle($data), + $this->renderSubtitle($data, 'mb-md-0'), + $this->renderButton($data) + ); + } + + private function renderCenteredLayout(array $data): string + { + return sprintf( + '
+ %s + %s +
+ %s +
+
', + $this->renderTitle($data), + $this->renderSubtitle($data, 'mb-3'), + $this->renderButton($data) + ); + } + + private function renderStackedLayout(array $data): string + { + return sprintf( + '
+ %s + %s +
+ %s +
+
', + $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(); + ?> +
+ +
+
buildContentTab(); ?>
+
buildButtonTab(); ?>
+
buildConfigTab(); ?>
+
buildStylesTab(); ?>
+
+
+ + + 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 principal del CTA (máx. 100 caracteres) +
+
+ + + 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 que aparece en el botón (máx. 50 caracteres) +
+
+ +
+ +
+ +
+
+ + Clase de Bootstrap Icons. Ver íconos + +
+ Íconos populares: + + + + + +
+
+
+ +
+ > + +
+ + +
+ > + +
+ + +
+ > + +
+
+ + + Solo administradores pueden usar JavaScript personalizado +
+
+ data['config']['height'] ?? '250px'; + $showOnMobile = $this->data['config']['show_on_mobile'] ?? true; + $customCssClass = $this->data['config']['custom_css_class'] ?? ''; + + ob_start(); + ?> +
+ + + Valor CSS válido (ej: 250px, 20rem) +
+
+
+ > + +
+
+
+ + + 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(); + ?> +
+
+ > + +
+
+ +
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ 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(); + ?> + + + + +
+ +
+ +
+ +

+ +

+ + + + rel="noopener noreferrer" + > + + + + + + + + +
+ + $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(); + ?> +
+ +
+
buildSectionTab(); ?>
+
buildContactInfoTab(); ?>
+
buildFormTab(); ?>
+
buildStylesTab(); ?>
+
+
+ data['section']['show_section'] ?? true; + $sectionTitle = $this->data['section']['section_title'] ?? '¿Tienes alguna pregunta?'; + $sectionSubtitle = $this->data['section']['section_subtitle'] ?? ''; + + ob_start(); + ?> +
+
+ > + +
+
+
+ + +
+
+ + +
+ data['contact_info'] ?? []; + + ob_start(); + ?> +
+
+
+ > + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ > + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ > + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ data['form'] ?? []; + + ob_start(); + ?> +
+ + +
+
+ + + Clase de Bootstrap Icons +
+
+ + +
+
+ + +
+
+ + + Deja vacío para usar el email del administrador +
+ data['styles'] ?? []; + + ob_start(); + ?> +
+ + + 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(); + ?> + + + + +
+
+
+
+

+ +

+ +
+
+ +
+
+ renderContactInfo($data); ?> +
+ +
+ renderForm($componentId, $data); ?> +
+
+
+
+ + +
+

Información de contacto

+
+ +
+
+ +
+

+

+
+
+
+ + + +
+
+ +
+

+

+
+
+
+ + + +
+
+ +
+

+

+
+
+
+ +
+
+ +
+
+ + + + +
+
+ +
Por favor ingresa tu nombre
+
+
+ +
+
+ +
Por favor ingresa tu WhatsApp
+
+
+ +
Por favor ingresa un email válido
+
+
+ +
+
+ +
+
+ + + +
+
+ + + componentId = $componentId; + $this->data = $data; + } + + public function build(): string + { + ob_start(); + ?> +
+ +
+
buildGeneralTab(); ?>
+
buildFieldsTab(); ?>
+
buildMessagesTab(); ?>
+
buildSettingsTab(); ?>
+
+
+ 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 que aparece en el encabezado del modal +
+ +
+ + + Texto descriptivo debajo del título +
+ +
+ +
Botón de Envío
+ +
+ + + Texto que aparece en el botón de envío +
+ +
+ + + Clase de Bootstrap Icons (ej: bi-send-fill). Ver íconos +
+ 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 que se muestra cuando el formulario se envía correctamente +
+ +
+ + + Mensaje que se muestra cuando ocurre un error al enviar +
+ +
+ + + 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 donde se recibirán los mensajes de contacto (vacío = admin 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 HTML del modal (no modificar) +
+ +
+ + + ID HTML del formulario (no modificar) +
+ +
+ + + 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(); + ?> + + + renderScript($data, $formId, $modalId, $componentId); ?> + + + + + 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
+ +
+ +
+ + + +
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(); + ?> +
+
+
+
+
+
+
+ > + +
+
+ +
+ + +
+ +
+ + + +
+
+
+ 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 de la sección de newsletter +
+ +
+ + + Texto descriptivo debajo del título +
+ +
+ + + Texto placeholder del campo de email +
+ +
+ + + 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 que aparece en el footer +
+ +
+
+ > + +
+ Si está activado, mostrará el año actual antes del texto de copyright +
+ +
+ 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. +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ data['styles'] ?? []; + $backgroundColor = $styles['background_color'] ?? 'bg-dark'; + $textColor = $styles['text_color'] ?? 'text-white'; + $linkHoverColor = $styles['link_hover_color'] ?? '#FF8600'; + + ob_start(); + ?> +
+ + + Clase de Bootstrap (ej: bg-dark, bg-secondary, bg-primary) +
+ +
+ + + Clase de Bootstrap (ej: text-white, text-light, text-dark) +
+ +
+ + + 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'] ?? []); ?> +
+
+
+ +
+ +
+ + +
+ + + +
+ + + +
+ +

+ + © + + +

+ '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->buildTabsNavigation(); + $html .= '
'; + $html .= $this->buildVisibilityTab($data, $componentId); + $html .= $this->buildCategoriesTab($data, $componentId); + $html .= $this->buildTitleTab($data, $componentId); + $html .= $this->buildStylesTab($data, $componentId); + $html .= '
'; + $html .= $this->buildFormScripts($componentId); + + return $html; + } + + private function buildTabsNavigation(): string + { + return << + + + + + +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('
', 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('
', 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('
', esc_attr($fieldId), esc_html($label)); + $html .= sprintf('
'; + 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('
', 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('
', esc_attr($fieldId), esc_html($label)); + $html .= '
'; + $html .= sprintf('', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), $attrString); + $html .= sprintf('', esc_attr($fieldId)); + $html .= '
'; + if (!empty($value)) { + $html .= sprintf('
Preview
', esc_url($value)); + } + $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/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', + 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 = '
'; + + // Tabs navigation + $html .= $this->buildTabsNavigation(); + + // Tab content + $html .= '
'; + + // Visibility tab + $html .= $this->buildVisibilityTab($data, $componentId); + + // Logo tab + $html .= $this->buildLogoTab($data, $componentId); + + // Menu tab + $html .= $this->buildMenuTab($data, $componentId); + + // CTA Button tab + $html .= $this->buildCtaButtonTab($data, $componentId); + + // Styles tab + $html .= $this->buildStylesTab($data, $componentId); + + $html .= '
'; + $html .= '
'; + + $html .= $this->buildFormScripts($componentId); + + return $html; + } + + private function buildTabsNavigation(): string + { + return << + + + + + + +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 = '
'; + $html .= '
'; + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($group), + esc_attr($name), + $checked + ); + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + $html .= '
'; + $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('', 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('', esc_attr($fieldId), esc_html($label)); + $html .= sprintf( + ''; + $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('', 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 = '
'; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + $html .= '
'; + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($group), + esc_attr($name), + esc_attr($value) + ); + $html .= sprintf('', esc_attr($value)); + $html .= '
'; + $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 = '
'; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + $html .= '
'; + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($group), + esc_attr($name), + esc_attr($value), + $attrString + ); + $html .= sprintf('', esc_attr($fieldId)); + $html .= '
'; + + if (!empty($value)) { + $html .= sprintf('
Preview
', esc_url($value)); + } + + $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( + ''; + + // 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( + '%s', + 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' => '
    %3$s
', + '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', + $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
    \n"; + } + + public function start_el(&$output, $item, $depth = 0, $args = null, $id = 0) + { + $indent = ($depth) ? str_repeat("\t", $depth) : ''; + + $classes = empty($item->classes) ? [] : (array) $item->classes; + $classes[] = 'nav-item'; + + if ($args->walker->has_children) { + $classes[] = 'dropdown'; + } + + $class_names = join(' ', apply_filters('nav_menu_css_class', array_filter($classes), $item, $args, $depth)); + $class_names = $class_names ? ' class="' . esc_attr($class_names) . '"' : ''; + + $id = apply_filters('nav_menu_item_id', 'menu-item-' . $item->ID, $item, $args, $depth); + $id = $id ? ' id="' . esc_attr($id) . '"' : ''; + + $output .= $indent . ''; + + $atts = []; + $atts['title'] = !empty($item->attr_title) ? $item->attr_title : ''; + $atts['target'] = !empty($item->target) ? $item->target : ''; + $atts['rel'] = !empty($item->xfn) ? $item->xfn : ''; + $atts['href'] = !empty($item->url) ? $item->url : ''; + + if ($depth === 0) { + $atts['class'] = 'nav-link'; + if ($args->walker->has_children) { + $atts['class'] .= ' dropdown-toggle'; + $atts['data-bs-toggle'] = 'dropdown'; + $atts['role'] = 'button'; + $atts['aria-expanded'] = 'false'; + } + } else { + $atts['class'] = 'dropdown-item'; + } + + if (in_array('current-menu-item', $classes)) { + $atts['class'] .= ' active'; + } + + $atts = apply_filters('nav_menu_link_attributes', $atts, $item, $args, $depth); + + $attributes = ''; + foreach ($atts as $attr => $value) { + if (!empty($value)) { + $value = ('href' === $attr) ? esc_url($value) : esc_attr($value); + $attributes .= ' ' . $attr . '="' . $value . '"'; + } + } + + $title = apply_filters('the_title', $item->title, $item->ID); + $title = apply_filters('nav_menu_item_title', $title, $item, $args, $depth); + + $item_output = $args->before; + $item_output .= ''; + $item_output .= $args->link_before . $title . $args->link_after; + $item_output .= ''; + $item_output .= $args->after; + + $output .= apply_filters('walker_nav_menu_start_el', $item_output, $item, $depth, $args); + } +} diff --git a/src/RelatedPosts/Infrastructure/Presentation/Admin/RelatedPostsFormBuilder.php b/src/RelatedPosts/Infrastructure/Presentation/Admin/RelatedPostsFormBuilder.php new file mode 100644 index 00000000..522490fe --- /dev/null +++ b/src/RelatedPosts/Infrastructure/Presentation/Admin/RelatedPostsFormBuilder.php @@ -0,0 +1,206 @@ +componentId = $componentId; + $this->data = $data; + } + + public function build(): string + { + ob_start(); + ?> + + data['visibility']['is_enabled'] ?? true; + $sectionTitle = $this->data['visibility']['section_title'] ?? 'Descubre Más Contenido'; + + ob_start(); + ?> +
    +
    + > + +
    + Activa o desactiva el componente de posts relacionados +
    +
    + + + Título que aparece antes del grid de posts +
    + data['query']['posts_per_page'] ?? 12; + $postSelection = $this->data['query']['post_selection'] ?? 'category'; + $excludeCurrent = $this->data['query']['exclude_current_post'] ?? true; + + ob_start(); + ?> +
    + + + Cantidad de posts a mostrar por página +
    +
    + + + Cómo seleccionar los posts relacionados +
    +
    +
    + > + +
    + Excluye el post actual de los resultados +
    + data['layout']['columns'] ?? '3'; + $cardHeight = $this->data['layout']['card_height'] ?? 'equal'; + + ob_start(); + ?> +
    + + + Número de columnas en el grid +
    +
    + + + Controla cómo se muestran las cards +
    + data['pagination']['show_pagination'] ?? true; + $paginationPosition = $this->data['pagination']['pagination_position'] ?? 'center'; + + ob_start(); + ?> +
    +
    + > + +
    + Muestra u oculta la paginación +
    +
    + + + Alineación de la paginación +
    + data['styles']['container_classes'] ?? 'my-5 related-posts'; + $gridGap = $this->data['styles']['grid_gap'] ?? '4'; + + ob_start(); + ?> +
    + + + Clases CSS adicionales para el contenedor principal +
    +
    + + + Espaciado entre cards +
    +
    +
    Notas:
    +
      +
    • Los posts se seleccionan según el criterio elegido (categoría, tags, etc.)
    • +
    • Si no hay posts relacionados, se mostrarán los más recientes
    • +
    • La paginación solo aparece si hay más de una página de resultados
    • +
    +
    + componentId; + } +} diff --git a/src/RelatedPosts/Infrastructure/Presentation/Public/RelatedPostsRenderer.php b/src/RelatedPosts/Infrastructure/Presentation/Public/RelatedPostsRenderer.php new file mode 100644 index 00000000..0bd5dcc4 --- /dev/null +++ b/src/RelatedPosts/Infrastructure/Presentation/Public/RelatedPostsRenderer.php @@ -0,0 +1,242 @@ +currentPostId = get_the_ID() ?: 0; + } + + public function render(Component $component): string + { + $data = $component->getData(); + + if (!$this->isEnabled($data)) { + return ''; + } + + $this->query = $this->buildQuery($data); + + if (!$this->query->have_posts()) { + $this->query = $this->buildFallbackQuery($data); + } + + if (!$this->query->have_posts()) { + wp_reset_postdata(); + return ''; + } + + $containerClasses = $data['styles']['container_classes'] ?? 'my-5 related-posts'; + $sectionTitle = $data['visibility']['section_title'] ?? 'Descubre Más Contenido'; + + ob_start(); + ?> +
    +

    + renderPostsGrid($data); ?> + renderPagination($data); ?> +
    + 'post', + 'post_status' => 'publish', + 'posts_per_page' => $postsPerPage, + 'paged' => get_query_var('paged') ? get_query_var('paged') : 1, + 'ignore_sticky_posts' => true, + ]; + + if ($excludeCurrent) { + $args['post__not_in'] = [$this->currentPostId]; + } + + switch ($postSelection) { + case 'category': + $args['category__in'] = wp_get_post_categories($this->currentPostId); + break; + + case 'tags': + $tags = wp_get_post_tags($this->currentPostId, ['fields' => 'ids']); + if (!empty($tags)) { + $args['tag__in'] = $tags; + } + break; + + case 'both': + $args['category__in'] = wp_get_post_categories($this->currentPostId); + $tags = wp_get_post_tags($this->currentPostId, ['fields' => 'ids']); + if (!empty($tags)) { + $args['tag__in'] = $tags; + } + break; + + case 'random': + $args['orderby'] = 'rand'; + break; + + case 'recent': + default: + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + break; + } + + return new WP_Query($args); + } + + private function buildFallbackQuery(array $data): WP_Query + { + $postsPerPage = $data['query']['posts_per_page'] ?? 12; + + $args = [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => $postsPerPage, + 'orderby' => 'date', + 'order' => 'DESC', + 'post__not_in' => [$this->currentPostId], + 'paged' => get_query_var('paged') ? get_query_var('paged') : 1, + ]; + + return new WP_Query($args); + } + + private function renderPostsGrid(array $data): string + { + $columns = $data['layout']['columns'] ?? '3'; + $gridGap = 'g-' . ($data['styles']['grid_gap'] ?? '4'); + $columnClass = $this->getColumnClass($columns); + + ob_start(); + ?> +
    + query->have_posts()) { + $this->query->the_post(); + echo $this->renderPostCard($data, $columnClass); + } + ?> +
    + 'col-12', + '2' => 'col-md-6', + '3' => 'col-md-4', + '4' => 'col-md-3', + ]; + + return $columnMap[$columns] ?? 'col-md-4'; + } + + private function renderPostCard(array $data, string $columnClass): string + { + $cardHeight = $data['layout']['card_height'] ?? 'equal'; + $heightClass = $cardHeight === 'equal' ? 'h-100' : ''; + $textCenterClass = $cardHeight === 'equal' ? 'text-center' : ''; + $dFlexClass = $cardHeight === 'equal' ? 'd-flex align-items-center justify-content-center' : ''; + + ob_start(); + ?> + + query->max_num_pages <= 1) { + return ''; + } + + $paginationPosition = $data['pagination']['pagination_position'] ?? 'center'; + $positionClass = 'justify-content-' . $paginationPosition; + + $currentPage = max(1, get_query_var('paged')); + $totalPages = $this->query->max_num_pages; + + ob_start(); + ?> + + componentId = $componentId; + $this->data = $data; + } + + public function build(): string + { + ob_start(); + ?> + + data['visibility']['is_enabled'] ?? true; + $showLabel = $this->data['visibility']['show_label'] ?? true; + $labelText = $this->data['visibility']['label_text'] ?? 'Compartir:'; + + ob_start(); + ?> +
    +
    + > + +
    + Activa o desactiva los botones de compartir en redes sociales +
    +
    +
    + > + +
    + Muestra u oculta el texto de etiqueta antes de los botones +
    +
    + + + Texto que aparece antes de los botones +
    + data['networks']['enabled_networks'] ?? ['facebook', 'instagram', 'linkedin', 'whatsapp', 'twitter', 'email']; + $showLabels = $this->data['networks']['show_network_labels'] ?? false; + + $networks = [ + 'facebook' => 'Facebook', + 'instagram' => 'Instagram', + 'linkedin' => 'LinkedIn', + 'whatsapp' => 'WhatsApp', + 'twitter' => 'Twitter / X', + 'email' => 'Email', + ]; + + ob_start(); + ?> +
    + +

    Selecciona qué redes sociales mostrar en los botones de compartir

    + $label): ?> +
    + > + +
    + +
    +
    +
    + > + +
    + Muestra el nombre de la red social junto al icono +
    + data['button_styles']['button_style'] ?? 'outline'; + $buttonSize = $this->data['button_styles']['button_size'] ?? 'sm'; + + ob_start(); + ?> +
    + + + Estilo visual de los botones +
    +
    + + + Tamaño de los botones +
    + data['advanced']['container_classes'] ?? 'my-5 py-4 border-top'; + $wrapperClasses = $this->data['advanced']['buttons_wrapper_classes'] ?? 'd-flex gap-2 flex-wrap share-buttons'; + + ob_start(); + ?> +
    + + + Clases CSS adicionales para el contenedor principal +
    +
    + + + Clases CSS para el wrapper de los botones +
    +
    +
    Notas:
    +
      +
    • Instagram no tiene API de compartir directa, se muestra como enlace inactivo (#)
    • +
    • Las URLs de compartir se generan automáticamente con el permalink del post
    • +
    • Todos los botones incluyen aria-label para lectores de pantalla
    • +
    +
    + componentId; + } +} diff --git a/src/ShareButtons/Infrastructure/Presentation/Public/ShareButtonsRenderer.php b/src/ShareButtons/Infrastructure/Presentation/Public/ShareButtonsRenderer.php new file mode 100644 index 00000000..67429759 --- /dev/null +++ b/src/ShareButtons/Infrastructure/Presentation/Public/ShareButtonsRenderer.php @@ -0,0 +1,159 @@ + [ + 'url_template' => 'https://www.facebook.com/sharer/sharer.php?u={url}', + 'icon_class' => 'bi bi-facebook', + 'button_class' => 'btn-outline-primary', + 'aria_label' => 'Compartir en Facebook', + ], + 'instagram' => [ + 'url_template' => '#', + 'icon_class' => 'bi bi-instagram', + 'button_class' => 'btn-outline-danger', + 'aria_label' => 'Compartir en Instagram', + ], + 'linkedin' => [ + 'url_template' => 'https://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}', + 'icon_class' => 'bi bi-linkedin', + 'button_class' => 'btn-outline-info', + 'aria_label' => 'Compartir en LinkedIn', + ], + 'whatsapp' => [ + 'url_template' => 'https://wa.me/?text={title}%20{url}', + 'icon_class' => 'bi bi-whatsapp', + 'button_class' => 'btn-outline-success', + 'aria_label' => 'Compartir en WhatsApp', + ], + 'twitter' => [ + 'url_template' => 'https://twitter.com/intent/tweet?url={url}&text={title}', + 'icon_class' => 'bi bi-twitter-x', + 'button_class' => 'btn-outline-dark', + 'aria_label' => 'Compartir en X', + ], + 'email' => [ + 'url_template' => 'mailto:?subject={title}&body={url}', + 'icon_class' => 'bi bi-envelope', + 'button_class' => 'btn-outline-secondary', + 'aria_label' => 'Compartir por Email', + ], + ]; + + public function render(Component $component): string + { + $data = $component->getData(); + + if (!$this->isEnabled($data)) { + return ''; + } + + $enabledNetworks = $data['networks']['enabled_networks'] ?? []; + if (empty($enabledNetworks)) { + return ''; + } + + $containerClasses = $data['advanced']['container_classes'] ?? 'my-5 py-4 border-top'; + $wrapperClasses = $data['advanced']['buttons_wrapper_classes'] ?? 'd-flex gap-2 flex-wrap share-buttons'; + + $html = sprintf('
    ', esc_attr($containerClasses)); + + if ($this->shouldShowLabel($data)) { + $labelText = $data['visibility']['label_text'] ?? 'Compartir:'; + $html .= sprintf('

    %s

    ', esc_html($labelText)); + } + + $html .= sprintf('
    ', esc_attr($wrapperClasses)); + $html .= $this->renderButtons($data, $enabledNetworks); + $html .= '
    '; + $html .= '
    '; + + return $html; + } + + private function isEnabled(array $data): bool + { + return isset($data['visibility']['is_enabled']) && + $data['visibility']['is_enabled'] === true; + } + + private function shouldShowLabel(array $data): bool + { + return isset($data['visibility']['show_label']) && + $data['visibility']['show_label'] === true; + } + + private function renderButtons(array $data, array $enabledNetworks): string + { + $postUrl = get_permalink() ?: ''; + $postTitle = get_the_title() ?: ''; + $buttonStyle = $data['button_styles']['button_style'] ?? 'outline'; + $buttonSize = $data['button_styles']['button_size'] ?? 'sm'; + $showLabels = $data['networks']['show_network_labels'] ?? false; + + $output = ''; + + foreach ($enabledNetworks as $network) { + if (!isset($this->networksConfig[$network])) { + continue; + } + + $networkConfig = $this->networksConfig[$network]; + $url = $this->buildShareUrl($network, $networkConfig['url_template'], $postUrl, $postTitle); + $buttonClass = $this->getButtonClass($networkConfig['button_class'], $buttonStyle, $buttonSize); + + $label = $showLabels ? ' ' . esc_html(ucfirst($network)) : ''; + + $output .= sprintf( + ' + %s + ', + esc_url($url), + esc_attr($buttonClass), + esc_attr($networkConfig['aria_label']), + esc_attr($networkConfig['icon_class']), + $label + ); + } + + return $output; + } + + private function buildShareUrl(string $network, string $template, string $postUrl, string $postTitle): string + { + if ($network === 'instagram') { + return '#'; + } + + $replacements = [ + '{url}' => urlencode($postUrl), + '{title}' => urlencode($postTitle), + ]; + + return str_replace(array_keys($replacements), array_values($replacements), $template); + } + + private function getButtonClass(string $networkClass, string $buttonStyle, string $buttonSize): string + { + $baseClass = 'btn'; + $sizeClass = 'btn-' . $buttonSize; + + if ($buttonStyle === 'solid') { + $networkClass = str_replace('btn-outline-', 'btn-', $networkClass); + } + + return "{$baseClass} {$networkClass} {$sizeClass}"; + } + + public function supports(string $componentType): bool + { + return $componentType === 'share-buttons'; + } +} diff --git a/src/Shared/Domain/.gitkeep b/src/Shared/Domain/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/Shared/Domain/ComponentDefaultsRepositoryInterface.php b/src/Shared/Domain/ComponentDefaultsRepositoryInterface.php new file mode 100644 index 00000000..d6236f24 --- /dev/null +++ b/src/Shared/Domain/ComponentDefaultsRepositoryInterface.php @@ -0,0 +1,76 @@ +getByName(ComponentName::fromString('top_bar')); + * $component = new Component( + * ComponentName::fromString('top_bar'), + * $defaults // configuración por defecto + * ); + * + * // Resetear componente a valores por defecto + * $component = $component->updateConfiguration($defaults); + * ``` + * + * @package ROITheme\Domain\Contracts + */ +interface ComponentDefaultsRepositoryInterface +{ + /** + * Obtener configuración por defecto de un componente + * + * @param ComponentName $name Nombre del componente + * @return ComponentConfiguration Configuración por defecto + */ + public function getByName(ComponentName $name): ComponentConfiguration; + + /** + * Guardar configuración por defecto para un componente + * + * @param ComponentName $name Nombre del componente + * @param ComponentConfiguration $configuration Configuración por defecto + * @return void + */ + public function save(ComponentName $name, ComponentConfiguration $configuration): void; + + /** + * Verificar si existen defaults para un componente + * + * @param ComponentName $name + * @return bool + */ + public function exists(ComponentName $name): bool; + + /** + * Obtener todos los defaults + * + * @return array Array asociativo nombre => configuración + */ + public function findAll(): array; + + /** + * Eliminar defaults de un componente + * + * @param ComponentName $name + * @return bool True si se eliminó, false si no existía + */ + public function delete(ComponentName $name): bool; +} diff --git a/src/Shared/Domain/ComponentRepositoryInterface.php b/src/Shared/Domain/ComponentRepositoryInterface.php new file mode 100644 index 00000000..e0a7eb3d --- /dev/null +++ b/src/Shared/Domain/ComponentRepositoryInterface.php @@ -0,0 +1,124 @@ +repository->save($component); + * } + * } + * ``` + * + * @package ROITheme\Domain\Contracts + */ +interface ComponentRepositoryInterface +{ + /** + * Guardar o actualizar un componente + * + * Si el componente ya existe (por nombre), se actualiza. + * Si no existe, se crea. + * + * @param Component $component Componente a guardar + * @return Component Componente guardado (con timestamps actualizados) + */ + public function save(Component $component): Component; + + /** + * Buscar componente por nombre + * + * @param ComponentName $name Nombre del componente + * @return Component|null Componente encontrado o null + */ + public function findByName(ComponentName $name): ?Component; + + /** + * Obtener componente por nombre (lanza excepción si no existe) + * + * @param ComponentName $name + * @return Component + * @throws ComponentNotFoundException + */ + public function getByName(ComponentName $name): Component; + + /** + * Obtener todos los componentes + * + * @return Component[] Array de componentes + */ + public function findAll(): array; + + /** + * Obtener componentes habilitados + * + * @return Component[] Array de componentes habilitados + */ + public function findEnabled(): array; + + /** + * Verificar si existe un componente con el nombre dado + * + * @param ComponentName $name + * @return bool + */ + public function exists(ComponentName $name): bool; + + /** + * Eliminar un componente + * + * @param ComponentName $name Nombre del componente a eliminar + * @return bool True si se eliminó, false si no existía + */ + public function delete(ComponentName $name): bool; + + /** + * Obtener cantidad total de componentes + * + * @return int + */ + public function count(): int; + + /** + * Obtener componentes por grupo de configuración + * + * Ejemplo: Obtener todos los componentes que tienen configuración de 'content' + * + * @param string $group Grupo de configuración (visibility, content, styles, general) + * @return Component[] + */ + public function findByConfigGroup(string $group): array; +} diff --git a/src/Shared/Domain/ValidationServiceInterface.php b/src/Shared/Domain/ValidationServiceInterface.php new file mode 100644 index 00000000..d5f85513 --- /dev/null +++ b/src/Shared/Domain/ValidationServiceInterface.php @@ -0,0 +1,199 @@ +validator->validate( + * $request->getData(), + * $request->getComponentName() + * ); + * + * if (!$result->isValid()) { + * throw new ValidationException($result->getErrors()); + * } + * + * // ... crear componente con datos validados + * } + * } + * ``` + * + * @package ROITheme\Domain\Contracts + */ +interface ValidationServiceInterface +{ + /** + * Validar datos de un componente + * + * @param array $data Datos a validar + * @param string $componentName Nombre del componente (para reglas específicas) + * @return ValidationResult Resultado de validación + */ + public function validate(array $data, string $componentName): ValidationResult; + + /** + * Sanitizar datos de entrada + * + * @param array $data Datos a sanitizar + * @param string $componentName Nombre del componente + * @return array Datos sanitizados + */ + public function sanitize(array $data, string $componentName): array; + + /** + * Validar una URL + * + * @param string $url URL a validar + * @return bool + */ + public function isValidUrl(string $url): bool; + + /** + * Validar un color hexadecimal + * + * @param string $color Color a validar (ej: #000000) + * @return bool + */ + public function isValidColor(string $color): bool; + + /** + * Validar nombre de componente + * + * @param string $name Nombre a validar + * @return bool + */ + public function isValidComponentName(string $name): bool; +} + +/** + * ValidationResult - Value Object para resultado de validación + * + * RESPONSABILIDAD: Encapsular resultado de una validación + * + * @package ROITheme\Domain\Contracts + */ +final class ValidationResult +{ + /** + * Constructor + * + * @param bool $isValid Si la validación pasó + * @param array $errors Array de errores (campo => mensaje) + * @param array $sanitizedData Datos sanitizados + */ + public function __construct( + private bool $isValid, + private array $errors = [], + private array $sanitizedData = [] + ) {} + + /** + * Verificar si la validación pasó + * + * @return bool + */ + public function isValid(): bool + { + return $this->isValid; + } + + /** + * Obtener errores de validación + * + * @return array Array asociativo campo => mensaje + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Obtener datos sanitizados + * + * @return array + */ + public function getSanitizedData(): array + { + return $this->sanitizedData; + } + + /** + * Verificar si hay error en campo específico + * + * @param string $field + * @return bool + */ + public function hasError(string $field): bool + { + return isset($this->errors[$field]); + } + + /** + * Obtener error de un campo específico + * + * @param string $field + * @return string|null + */ + public function getError(string $field): ?string + { + return $this->errors[$field] ?? null; + } + + /** + * Factory: Resultado exitoso + * + * @param array $sanitizedData + * @return self + */ + public static function success(array $sanitizedData): self + { + return new self( + isValid: true, + errors: [], + sanitizedData: $sanitizedData + ); + } + + /** + * Factory: Resultado con errores + * + * @param array $errors + * @return self + */ + public static function failure(array $errors): self + { + return new self( + isValid: false, + errors: $errors, + sanitizedData: [] + ); + } +} diff --git a/src/TableOfContents/Infrastructure/Presentation/Admin/TableOfContentsFormBuilder.php b/src/TableOfContents/Infrastructure/Presentation/Admin/TableOfContentsFormBuilder.php new file mode 100644 index 00000000..383e45bb --- /dev/null +++ b/src/TableOfContents/Infrastructure/Presentation/Admin/TableOfContentsFormBuilder.php @@ -0,0 +1,309 @@ +componentId = $componentId; + $this->data = $data; + } + + public function build(): string + { + ob_start(); + ?> +
    + +
    +
    buildVisibilityTab(); ?>
    +
    buildConfigTab(); ?>
    +
    buildManualItemsTab(); ?>
    +
    buildStylesTab(); ?>
    +
    +
    + data['visibility']['is_enabled'] ?? true; + $sticky = $this->data['visibility']['sticky'] ?? true; + $showOnMobile = $this->data['visibility']['show_on_mobile'] ?? false; + + ob_start(); + ?> +
    +
    + > + +
    + Activa o desactiva el componente de tabla de contenido +
    +
    +
    + > + +
    + Mantiene el TOC visible mientras el usuario navega +
    +
    +
    + > + +
    + Muestra el TOC en pantallas pequeñas +
    + data['config']['title'] ?? 'Tabla de Contenido'; + $headingLevels = $this->data['config']['heading_levels'] ?? ['h2', 'h3']; + $autoGenerate = $this->data['config']['auto_generate'] ?? true; + $offsetTop = $this->data['config']['offset_top'] ?? 100; + $smoothScroll = $this->data['config']['smooth_scroll'] ?? true; + $maxHeight = $this->data['config']['max_height'] ?? 'calc(100vh - 400px)'; + $customCssClass = $this->data['config']['custom_css_class'] ?? ''; + + ob_start(); + ?> +
    + + + Título que aparece en el encabezado del TOC +
    +
    + + Selecciona qué niveles incluir en el TOC + 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6']; + foreach ($availableLevels as $level => $label): + ?> +
    + > + +
    + +
    +
    +
    + > + +
    + Si está desactivado, usa items manuales +
    +
    + + + Offset desde el top para el ScrollSpy +
    +
    +
    + > + +
    + Activar scroll suave al hacer clic en enlaces +
    +
    + + + CSS válido (ej: 500px, calc(100vh - 200px)) +
    +
    + + + Opcional: agregar clase CSS adicional +
    + data['manual_items']['items'] ?? []; + + ob_start(); + ?> +
    + Nota: Los items manuales solo se usan si "Generar automáticamente" está desactivado en la pestaña Configuración. +
    +
    + '', 'anchor' => '', 'level' => 2]]; + } + + foreach ($items as $index => $item): + ?> +
    +
    + Item # + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    + + + + data['styles']['background_color'] ?? '#ffffff'; + $borderColor = $this->data['styles']['border_color'] ?? '#E6E9ED'; + $titleColor = $this->data['styles']['title_color'] ?? '#0E2337'; + $linkColor = $this->data['styles']['link_color'] ?? '#6B7280'; + $linkHoverColor = $this->data['styles']['link_hover_color'] ?? '#0E2337'; + $activeBorderColor = $this->data['styles']['active_border_color'] ?? '#0E2337'; + $activeBgColor = $this->data['styles']['active_bg_color'] ?? '#F9FAFB'; + + ob_start(); + ?> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    Notas sobre el TOC:
    +
      +
    • El ScrollSpy requiere Bootstrap 5 cargado en el sitio
    • +
    • Los IDs se generan automáticamente desde los encabezados del contenido
    • +
    • El TOC es responsive y se adapta a pantallas pequeñas
    • +
    +
    + componentId; + } +} diff --git a/src/TableOfContents/Infrastructure/Presentation/Public/TableOfContentsRenderer.php b/src/TableOfContents/Infrastructure/Presentation/Public/TableOfContentsRenderer.php new file mode 100644 index 00000000..5b2e8fe1 --- /dev/null +++ b/src/TableOfContents/Infrastructure/Presentation/Public/TableOfContentsRenderer.php @@ -0,0 +1,265 @@ +currentPostId = get_the_ID() ?: 0; + } + + public function render(Component $component): string + { + $data = $component->getData(); + + if (!$this->isEnabled($data)) { + return ''; + } + + $autoGenerate = $data['config']['auto_generate'] ?? true; + $tocItems = $autoGenerate + ? $this->generateTocFromContent($data) + : $this->getManualItems($data); + + if (empty($tocItems)) { + return ''; + } + + $componentId = $component->getId(); + $customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []); + $stickyClass = ($data['visibility']['sticky'] ?? true) ? 'sidebar-sticky' : ''; + $mobileClass = !($data['visibility']['show_on_mobile'] ?? false) ? 'd-none d-lg-block' : ''; + $title = $data['config']['title'] ?? 'Tabla de Contenido'; + $maxHeight = $data['config']['max_height'] ?? 'calc(100vh - 400px)'; + $offsetTop = $data['config']['offset_top'] ?? 100; + + ob_start(); + ?> + + + + +
    + +

    + +
      + + 2 ? 'toc-level-' . $level : ''; + ?> +
    1. + + + +
    2. + +
    +
    + + + + + + post_content)) { + return []; + } + + $headingLevels = $data['config']['heading_levels'] ?? ['h2', 'h3']; + $content = apply_filters('the_content', $post->post_content); + + $dom = new DOMDocument(); + libxml_use_internal_errors(true); + $dom->loadHTML('' . $content); + libxml_clear_errors(); + + $xpath = new DOMXPath($dom); + $xpathQuery = implode(' | ', array_map(function($level) { + return '//' . $level; + }, $headingLevels)); + + $headings = $xpath->query($xpathQuery); + + if ($headings->length === 0) { + return []; + } + + $tocItems = []; + $headingCounter = []; + + foreach ($headings as $heading) { + $tagName = strtolower($heading->tagName); + $level = intval(substr($tagName, 1)); + $text = trim($heading->textContent); + + if (empty($text)) { + continue; + } + + $existingId = $heading->getAttribute('id'); + $anchor = !empty($existingId) + ? $existingId + : $this->generateAnchorId($text, $headingCounter); + + $tocItems[] = [ + 'text' => $text, + 'anchor' => $anchor, + 'level' => $level + ]; + } + + return $tocItems; + } + + private function getManualItems(array $data): array + { + return $data['manual_items']['items'] ?? []; + } + + private function generateAnchorId(string $text, array &$counter): string + { + $id = strtolower($text); + $id = remove_accents($id); + $id = preg_replace('/[^a-z0-9]+/', '-', $id); + $id = trim($id, '-'); + + $baseId = $id; + $count = 1; + + while (isset($counter[$id])) { + $id = $baseId . '-' . $count; + $count++; + } + + $counter[$id] = true; + + return $id; + } + + private function generateCustomStyles(string $componentId, array $styles): string + { + if (empty($styles)) { + return ''; + } + + $css = []; + + if (isset($styles['background_color'])) { + $css[] = "#$componentId.toc-container { background: {$styles['background_color']}; }"; + } + + if (isset($styles['border_color'])) { + $css[] = "#$componentId.toc-container { border-color: {$styles['border_color']}; }"; + } + + if (isset($styles['title_color'])) { + $css[] = "#$componentId.toc-container h4 { color: {$styles['title_color']}; }"; + } + + if (isset($styles['link_color'])) { + $css[] = "#$componentId .toc-link { color: {$styles['link_color']}; }"; + } + + if (isset($styles['link_hover_color'])) { + $css[] = "#$componentId .toc-link:hover { color: {$styles['link_hover_color']}; }"; + } + + if (isset($styles['active_border_color'])) { + $css[] = "#$componentId .toc-link.active { border-left-color: {$styles['active_border_color']}; }"; + } + + if (isset($styles['active_bg_color'])) { + $css[] = "#$componentId .toc-link.active { background-color: {$styles['active_bg_color']}; }"; + } + + return implode("\n", $css); + } + + public function supports(string $componentType): bool + { + return $componentType === 'table-of-contents'; + } +} diff --git a/src/TopNotificationBar/Infrastructure/Presentation/Admin/TopNotificationBarFormBuilder.php b/src/TopNotificationBar/Infrastructure/Presentation/Admin/TopNotificationBarFormBuilder.php new file mode 100644 index 00000000..19ef1acb --- /dev/null +++ b/src/TopNotificationBar/Infrastructure/Presentation/Admin/TopNotificationBarFormBuilder.php @@ -0,0 +1,691 @@ +componentId = $componentId; + $this->data = $data; + } + + public function build(): string + { + $data = $this->data; + $componentId = $this->componentId; + + $html = '
    '; + + // Sección de Visibilidad + $html .= $this->buildVisibilitySection($data, $componentId); + + // Sección de Contenido + $html .= $this->buildContentSection($data, $componentId); + + // Sección de Estilos + $html .= $this->buildStylesSection($data, $componentId); + + // Vista previa + $html .= $this->buildPreviewSection($data); + + $html .= '
    '; + + // Agregar scripts de formulario + $html .= $this->buildFormScripts($componentId); + + return $html; + } + + private function buildVisibilitySection(array $data, string $componentId): string + { + $html = '
    '; + $html .= '

    Visibilidad

    '; + $html .= '
    '; + + // Is Enabled + $isEnabled = $data['visibility']['is_enabled'] ?? true; + $html .= $this->buildToggle( + 'is_enabled', + 'Mostrar barra de notificación', + $isEnabled, + $componentId, + 'Activa o desactiva la barra de notificación superior' + ); + + // Show On Pages + $showOn = $data['visibility']['show_on_pages'] ?? 'all'; + $html .= $this->buildSelect( + 'show_on_pages', + 'Mostrar en', + $showOn, + [ + 'all' => 'Todas las páginas', + 'home' => 'Solo página de inicio', + 'posts' => 'Solo posts individuales', + 'pages' => 'Solo páginas', + 'custom' => 'Páginas específicas' + ], + $componentId, + 'Define en qué páginas se mostrará la barra' + ); + + // Custom Page IDs + $customPageIds = $data['visibility']['custom_page_ids'] ?? ''; + $html .= $this->buildTextField( + 'custom_page_ids', + 'IDs de páginas específicas', + $customPageIds, + $componentId, + 'IDs de páginas separados por comas', + 'Ej: 1,5,10', + ['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom'] + ); + + // Hide On Mobile + $hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false; + $html .= $this->buildToggle( + 'hide_on_mobile', + 'Ocultar en dispositivos móviles', + $hideOnMobile, + $componentId, + 'Oculta la barra en pantallas menores a 768px' + ); + + // Is Dismissible + $isDismissible = $data['visibility']['is_dismissible'] ?? false; + $html .= $this->buildToggle( + 'is_dismissible', + 'Permitir cerrar', + $isDismissible, + $componentId, + 'Agrega botón X para que el usuario pueda cerrar la barra' + ); + + // Dismissible Cookie Days + $cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7; + $html .= $this->buildNumberField( + 'dismissible_cookie_days', + 'Días antes de volver a mostrar', + $cookieDays, + $componentId, + 'Días que permanece oculta después de cerrarla', + 1, + 365, + ['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true'] + ); + + $html .= '
    '; + $html .= '
    '; + + return $html; + } + + private function buildContentSection(array $data, string $componentId): string + { + $html = '
    '; + $html .= '

    Contenido

    '; + $html .= '
    '; + + // Icon Type + $iconType = $data['content']['icon_type'] ?? 'bootstrap'; + $html .= $this->buildSelect( + 'icon_type', + 'Tipo de ícono', + $iconType, + [ + 'bootstrap' => 'Bootstrap Icons', + 'custom' => 'Imagen personalizada', + 'none' => 'Sin ícono' + ], + $componentId, + 'Selecciona el tipo de ícono a mostrar' + ); + + // Bootstrap Icon + $bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill'; + $html .= $this->buildTextField( + 'bootstrap_icon', + 'Clase de ícono Bootstrap', + $bootstrapIcon, + $componentId, + 'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)', + 'Ej: bi-megaphone-fill', + ['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap'] + ); + + // Custom Icon URL + $customIconUrl = $data['content']['custom_icon_url'] ?? ''; + $html .= $this->buildMediaField( + 'custom_icon_url', + 'Imagen personalizada', + $customIconUrl, + $componentId, + 'Sube una imagen personalizada (recomendado: PNG 24x24px)', + ['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom'] + ); + + // Announcement Label + $announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:'; + $html .= $this->buildTextField( + 'announcement_label', + 'Etiqueta del anuncio', + $announcementLabel, + $componentId, + 'Texto destacado en negrita antes del mensaje', + 'Ej: Nuevo:, Importante:, Aviso:' + ); + + // Announcement Text + $announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.'; + $html .= $this->buildTextArea( + 'announcement_text', + 'Texto del anuncio', + $announcementText, + $componentId, + 'Mensaje principal del anuncio (máximo 200 caracteres)', + 3 + ); + + // Link Enabled + $linkEnabled = $data['content']['link_enabled'] ?? true; + $html .= $this->buildToggle( + 'link_enabled', + 'Mostrar enlace', + $linkEnabled, + $componentId, + 'Activa o desactiva el enlace de acción' + ); + + // Link Text + $linkText = $data['content']['link_text'] ?? 'Ver Catálogo'; + $html .= $this->buildTextField( + 'link_text', + 'Texto del enlace', + $linkText, + $componentId, + 'Texto del enlace de acción', + '', + ['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true'] + ); + + // Link URL + $linkUrl = $data['content']['link_url'] ?? '#'; + $html .= $this->buildUrlField( + 'link_url', + 'URL del enlace', + $linkUrl, + $componentId, + 'URL de destino del enlace', + 'https://', + ['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true'] + ); + + // Link Target + $linkTarget = $data['content']['link_target'] ?? '_self'; + $html .= $this->buildSelect( + 'link_target', + 'Abrir enlace en', + $linkTarget, + [ + '_self' => 'Misma ventana', + '_blank' => 'Nueva ventana' + ], + $componentId, + 'Define cómo se abrirá el enlace', + ['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true'] + ); + + $html .= '
    '; + $html .= '
    '; + + return $html; + } + + private function buildStylesSection(array $data, string $componentId): string + { + $html = '
    '; + $html .= '

    Estilos

    '; + $html .= '
    '; + + // Background Color + $bgColor = $data['styles']['background_color'] ?? '#FF8600'; + $html .= $this->buildColorField( + 'background_color', + 'Color de fondo', + $bgColor, + $componentId, + 'Color de fondo de la barra (por defecto: orange primary)' + ); + + // Text Color + $textColor = $data['styles']['text_color'] ?? '#FFFFFF'; + $html .= $this->buildColorField( + 'text_color', + 'Color del texto', + $textColor, + $componentId, + 'Color del texto del anuncio' + ); + + // Link Color + $linkColor = $data['styles']['link_color'] ?? '#FFFFFF'; + $html .= $this->buildColorField( + 'link_color', + 'Color del enlace', + $linkColor, + $componentId, + 'Color del enlace de acción' + ); + + // Font Size + $fontSize = $data['styles']['font_size'] ?? 'small'; + $html .= $this->buildSelect( + 'font_size', + 'Tamaño de fuente', + $fontSize, + [ + 'extra-small' => 'Muy pequeño (0.75rem)', + 'small' => 'Pequeño (0.875rem)', + 'normal' => 'Normal (1rem)', + 'large' => 'Grande (1.125rem)' + ], + $componentId, + 'Tamaño del texto del anuncio' + ); + + // Padding Vertical + $padding = $data['styles']['padding_vertical'] ?? 'normal'; + $html .= $this->buildSelect( + 'padding_vertical', + 'Padding vertical', + $padding, + [ + 'compact' => 'Compacto (0.5rem)', + 'normal' => 'Normal (0.75rem)', + 'spacious' => 'Espacioso (1rem)' + ], + $componentId, + 'Espaciado vertical interno de la barra' + ); + + // Text Alignment + $alignment = $data['styles']['text_alignment'] ?? 'center'; + $html .= $this->buildSelect( + 'text_alignment', + 'Alineación del texto', + $alignment, + [ + 'left' => 'Izquierda', + 'center' => 'Centro', + 'right' => 'Derecha' + ], + $componentId, + 'Alineación del contenido de la barra' + ); + + // Animation Enabled + $animationEnabled = $data['styles']['animation_enabled'] ?? false; + $html .= $this->buildToggle( + 'animation_enabled', + 'Activar animación', + $animationEnabled, + $componentId, + 'Activa animación de entrada al cargar la página' + ); + + // Animation Type + $animationType = $data['styles']['animation_type'] ?? 'slide-down'; + $html .= $this->buildSelect( + 'animation_type', + 'Tipo de animación', + $animationType, + [ + 'slide-down' => 'Deslizar desde arriba', + 'fade-in' => 'Aparecer gradualmente' + ], + $componentId, + 'Tipo de animación de entrada', + ['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true'] + ); + + $html .= '
    '; + $html .= '
    '; + + return $html; + } + + private function buildPreviewSection(array $data): string + { + $html = '
    '; + $html .= '

    Vista Previa

    '; + $html .= '
    '; + $html .= '
    '; + $html .= '

    La vista previa se actualizará automáticamente al modificar los campos.

    '; + $html .= '
    '; + $html .= '
    '; + $html .= '
    '; + + return $html; + } + + private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string + { + $fieldId = "roi_{$componentId}_{$name}"; + $checked = $value ? 'checked' : ''; + + $html = '
    '; + $html .= '
    '; + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($name), + $checked + ); + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + $html .= '
    '; + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $html .= '
    '; + + return $html; + } + + private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string + { + $fieldId = "roi_{$componentId}_{$name}"; + + $html = '
    '; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + + $attrString = $this->buildAttributesString($attrs); + + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($name), + esc_attr($value), + esc_attr($placeholder), + $attrString + ); + + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $html .= '
    '; + + return $html; + } + + private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string + { + $fieldId = "roi_{$componentId}_{$name}"; + + $html = '
    '; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + + $attrString = $this->buildAttributesString($attrs); + + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($name), + $rows, + $attrString, + esc_textarea($value) + ); + + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $html .= '
    '; + + return $html; + } + + private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string + { + $fieldId = "roi_{$componentId}_{$name}"; + + $html = '
    '; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + + $attrString = $this->buildAttributesString($attrs); + + $html .= sprintf( + ''; + + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $html .= '
    '; + + return $html; + } + + private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string + { + $fieldId = "roi_{$componentId}_{$name}"; + + $html = '
    '; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + + $attrs['type'] = 'number'; + if ($min !== null) { + $attrs['min'] = $min; + } + if ($max !== null) { + $attrs['max'] = $max; + } + + $attrString = $this->buildAttributesString($attrs); + + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($name), + esc_attr($value), + $attrString + ); + + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $html .= '
    '; + + return $html; + } + + private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string + { + $attrs['type'] = 'url'; + return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs); + } + + private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string + { + $fieldId = "roi_{$componentId}_{$name}"; + + $html = '
    '; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + $html .= '
    '; + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($name), + esc_attr($value) + ); + $html .= sprintf( + '', + esc_attr($value) + ); + $html .= '
    '; + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $html .= '
    '; + + return $html; + } + + private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string + { + $fieldId = "roi_{$componentId}_{$name}"; + + $html = '
    '; + $html .= sprintf('', esc_attr($fieldId), esc_html($label)); + $html .= '
    '; + + $attrString = $this->buildAttributesString($attrs); + + $html .= sprintf( + '', + esc_attr($fieldId), + esc_attr($componentId), + esc_attr($name), + esc_attr($value), + $attrString + ); + $html .= sprintf( + '', + esc_attr($fieldId) + ); + $html .= '
    '; + + if (!empty($value)) { + $html .= sprintf('
    Preview
    ', esc_url($value)); + } + + if (!empty($description)) { + $html .= sprintf('%s', esc_html($description)); + } + $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/TopNotificationBar/Infrastructure/Presentation/Public/TopNotificationBarRenderer.php b/src/TopNotificationBar/Infrastructure/Presentation/Public/TopNotificationBarRenderer.php new file mode 100644 index 00000000..016a677c --- /dev/null +++ b/src/TopNotificationBar/Infrastructure/Presentation/Public/TopNotificationBarRenderer.php @@ -0,0 +1,348 @@ +getData(); + + // Verificar si el componente está habilitado + if (!$this->isEnabled($data)) { + return ''; + } + + // Verificar si debe mostrarse en la página actual + if (!$this->shouldShowOnCurrentPage($data)) { + return ''; + } + + // Verificar si el usuario ha cerrado la barra (si es dismissible) + if ($this->isDismissed($data)) { + return ''; + } + + $classes = $this->buildClasses($data); + $styles = $this->buildInlineStyles($data); + $content = $this->buildContent($data); + + $html = sprintf( + '
    %s
    ', + esc_attr($classes), + $styles ? ' style="' . esc_attr($styles) . '"' : '', + $this->buildDataAttributes($data), + $content + ); + + // Agregar script si es dismissible + if ($this->isDismissible($data)) { + $html .= $this->buildDismissScript($data); + } + + // Agregar estilos de animación si está habilitada + if ($this->isAnimationEnabled($data)) { + $html .= $this->buildAnimationStyles($data); + } + + return $html; + } + + private function isEnabled(array $data): bool + { + return isset($data['visibility']['is_enabled']) && + $data['visibility']['is_enabled'] === true; + } + + private function shouldShowOnCurrentPage(array $data): bool + { + $showOn = $data['visibility']['show_on_pages'] ?? 'all'; + + switch ($showOn) { + case 'all': + return true; + + case 'home': + return is_front_page(); + + case 'posts': + return is_single(); + + case 'pages': + return is_page(); + + case 'custom': + $pageIds = $data['visibility']['custom_page_ids'] ?? ''; + $allowedIds = array_map('trim', explode(',', $pageIds)); + return in_array((string)get_the_ID(), $allowedIds, true); + + default: + return true; + } + } + + private function isDismissed(array $data): bool + { + if (!$this->isDismissible($data)) { + return false; + } + + $cookieName = 'roi_notification_bar_dismissed'; + return isset($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] === '1'; + } + + private function isDismissible(array $data): bool + { + return isset($data['visibility']['is_dismissible']) && + $data['visibility']['is_dismissible'] === true; + } + + private function isAnimationEnabled(array $data): bool + { + return isset($data['styles']['animation_enabled']) && + $data['styles']['animation_enabled'] === true; + } + + private function buildClasses(array $data): string + { + $classes = ['top-notification-bar']; + + // Ocultar en móvil si está configurado + if (isset($data['visibility']['hide_on_mobile']) && $data['visibility']['hide_on_mobile']) { + $classes[] = 'd-none'; + $classes[] = 'd-md-block'; + } + + // Agregar clase de animación + if ($this->isAnimationEnabled($data)) { + $animationType = $data['styles']['animation_type'] ?? 'slide-down'; + $classes[] = 'roi-animated'; + $classes[] = 'roi-' . $animationType; + } + + return implode(' ', $classes); + } + + private function buildInlineStyles(array $data): string + { + $styles = []; + + // Color de fondo + if (!empty($data['styles']['background_color'])) { + $styles[] = 'background-color: ' . $data['styles']['background_color']; + } + + // Color de texto + if (!empty($data['styles']['text_color'])) { + $styles[] = 'color: ' . $data['styles']['text_color']; + } + + // Tamaño de fuente + $fontSize = $data['styles']['font_size'] ?? 'small'; + $fontSizes = [ + 'extra-small' => '0.75rem', + 'small' => '0.875rem', + 'normal' => '1rem', + 'large' => '1.125rem' + ]; + if (isset($fontSizes[$fontSize])) { + $styles[] = 'font-size: ' . $fontSizes[$fontSize]; + } + + // Padding vertical + $padding = $data['styles']['padding_vertical'] ?? 'normal'; + $paddings = [ + 'compact' => '0.5rem 0', + 'normal' => '0.75rem 0', + 'spacious' => '1rem 0' + ]; + if (isset($paddings[$padding])) { + $styles[] = 'padding: ' . $paddings[$padding]; + } + + return implode('; ', $styles); + } + + private function buildContent(array $data): string + { + $alignment = $data['styles']['text_alignment'] ?? 'center'; + $alignmentClasses = [ + 'left' => 'justify-content-start', + 'center' => 'justify-content-center', + 'right' => 'justify-content-end' + ]; + $alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center'; + + $html = '
    '; + $html .= sprintf('
    ', esc_attr($alignmentClass)); + + // Ícono + $html .= $this->buildIcon($data); + + // Texto del anuncio + $html .= $this->buildAnnouncementText($data); + + // Enlace + $html .= $this->buildLink($data); + + // Botón de cerrar (si es dismissible) + if ($this->isDismissible($data)) { + $html .= $this->buildDismissButton(); + } + + $html .= '
    '; + $html .= '
    '; + + return $html; + } + + private function buildIcon(array $data): string + { + $iconType = $data['content']['icon_type'] ?? 'bootstrap'; + + switch ($iconType) { + case 'bootstrap': + $iconClass = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill'; + // Asegurar que tenga el prefijo 'bi' + if (strpos($iconClass, 'bi-') !== 0) { + $iconClass = 'bi-' . $iconClass; + } + return sprintf('', esc_attr($iconClass)); + + case 'custom': + $iconUrl = $data['content']['custom_icon_url'] ?? ''; + if (!empty($iconUrl)) { + return sprintf( + 'Icon', + esc_url($iconUrl) + ); + } + return ''; + + case 'none': + default: + return ''; + } + } + + private function buildAnnouncementText(array $data): string + { + $label = $data['content']['announcement_label'] ?? ''; + $text = $data['content']['announcement_text'] ?? ''; + + if (empty($text)) { + return ''; + } + + $html = ''; + + if (!empty($label)) { + $html .= sprintf('%s ', esc_html($label)); + } + + $html .= esc_html($text); + $html .= ''; + + return $html; + } + + private function buildLink(array $data): string + { + $linkEnabled = $data['content']['link_enabled'] ?? true; + + if (!$linkEnabled) { + return ''; + } + + $linkText = $data['content']['link_text'] ?? ''; + $linkUrl = $data['content']['link_url'] ?? '#'; + $linkTarget = $data['content']['link_target'] ?? '_self'; + $linkColor = $data['styles']['link_color'] ?? '#FFFFFF'; + + if (empty($linkText)) { + return ''; + } + + return sprintf( + '%s', + esc_url($linkUrl), + esc_attr($linkTarget), + esc_attr($linkColor), + esc_html($linkText) + ); + } + + private function buildDismissButton(): string + { + return ''; + } + + private function buildDataAttributes(array $data): string + { + if (!$this->isDismissible($data)) { + return ''; + } + + $days = $data['visibility']['dismissible_cookie_days'] ?? 7; + return sprintf(' data-dismissible-days="%d"', (int)$days); + } + + private function buildDismissScript(array $data): string + { + $days = $data['visibility']['dismissible_cookie_days'] ?? 7; + + return << +SCRIPT; + } + + private function buildAnimationStyles(array $data): string + { + $animationType = $data['styles']['animation_type'] ?? 'slide-down'; + + $animations = [ + 'slide-down' => [ + 'keyframes' => '@keyframes roiSlideDown { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }', + 'animation' => 'roiSlideDown 0.5s ease-out' + ], + 'fade-in' => [ + 'keyframes' => '@keyframes roiFadeIn { from { opacity: 0; } to { opacity: 1; } }', + 'animation' => 'roiFadeIn 0.5s ease-out' + ] + ]; + + $anim = $animations[$animationType] ?? $animations['slide-down']; + + return << +STYLE; + } + + public function supports(string $componentType): bool + { + return $componentType === 'top_notification_bar'; + } +}