Fase-01: Preparación del entorno y estructura inicial
- Verificación de entorno XAMPP (PHP 8.0.30, Composer 2.9.1, WP-CLI 2.12.0) - Configuración de Composer con PSR-4 para 24 namespaces - Configuración de PHPUnit con 140 tests preparados - Configuración de PHPCS con WordPress Coding Standards - Scripts de backup y rollback con mejoras de seguridad - Estructura de contextos (admin/, public/, shared/) - Schemas JSON para 11 componentes del sistema - Código fuente inicial con arquitectura limpia en src/ - Documentación de procedimientos de emergencia 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
150
schemas/components/contact-form-section.json
Normal file
150
schemas/components/contact-form-section.json
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
{
|
||||||
|
"component_name": "contact-form-section",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Sección de contacto con información y formulario funcional mediante AJAX",
|
||||||
|
"groups": {
|
||||||
|
"section": {
|
||||||
|
"label": "Configuración de la Sección",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"show_section": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar sección",
|
||||||
|
"default": true,
|
||||||
|
"description": "Activar o desactivar la sección completa"
|
||||||
|
},
|
||||||
|
"section_title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título de la sección",
|
||||||
|
"default": "¿Tienes alguna pregunta?",
|
||||||
|
"required": true,
|
||||||
|
"description": "Título principal de la sección de contacto"
|
||||||
|
},
|
||||||
|
"section_subtitle": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Subtítulo",
|
||||||
|
"default": "Completa el formulario y nuestro equipo te responderá en menos de 24 horas.",
|
||||||
|
"description": "Descripción o subtítulo de la sección"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact_info": {
|
||||||
|
"label": "Información de Contacto",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"phone_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar teléfono",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"phone_label": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Etiqueta de teléfono",
|
||||||
|
"default": "Teléfono"
|
||||||
|
},
|
||||||
|
"phone_value": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Número de teléfono",
|
||||||
|
"default": "+52 55 1234 5678"
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar email",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_label": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Etiqueta de email",
|
||||||
|
"default": "Email"
|
||||||
|
},
|
||||||
|
"email_value": {
|
||||||
|
"type": "email",
|
||||||
|
"label": "Dirección de email",
|
||||||
|
"default": "contacto@example.com"
|
||||||
|
},
|
||||||
|
"location_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar ubicación",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"location_label": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Etiqueta de ubicación",
|
||||||
|
"default": "Ubicación"
|
||||||
|
},
|
||||||
|
"location_value": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Ubicación",
|
||||||
|
"default": "Ciudad de México, México"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"label": "Configuración del Formulario",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"submit_button_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del botón",
|
||||||
|
"default": "Enviar Mensaje",
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto del botón de envío"
|
||||||
|
},
|
||||||
|
"submit_button_icon": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Ícono del botón",
|
||||||
|
"default": "bi-send-fill",
|
||||||
|
"description": "Clase de Bootstrap Icons"
|
||||||
|
},
|
||||||
|
"success_message": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Mensaje de éxito",
|
||||||
|
"default": "¡Gracias! Tu mensaje ha sido enviado correctamente. Te responderemos pronto.",
|
||||||
|
"description": "Mensaje al enviar exitosamente"
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Mensaje de error",
|
||||||
|
"default": "Hubo un error al enviar el mensaje. Por favor, intenta de nuevo.",
|
||||||
|
"description": "Mensaje al fallar el envío"
|
||||||
|
},
|
||||||
|
"to_email": {
|
||||||
|
"type": "email",
|
||||||
|
"label": "Email de destino",
|
||||||
|
"default": "",
|
||||||
|
"description": "Email donde se recibirán los mensajes (deja vacío para usar el admin email)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"background_color": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase de fondo",
|
||||||
|
"default": "bg-secondary bg-opacity-25",
|
||||||
|
"description": "Clase de Bootstrap para el fondo"
|
||||||
|
},
|
||||||
|
"icon_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de íconos",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color de los íconos de contacto"
|
||||||
|
},
|
||||||
|
"button_bg_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del botón",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color de fondo del botón"
|
||||||
|
},
|
||||||
|
"button_hover_bg": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del botón (hover)",
|
||||||
|
"default": "#FF6B00",
|
||||||
|
"description": "Color de fondo del botón al hover"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
schemas/components/contact-modal.json
Normal file
186
schemas/components/contact-modal.json
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"component_name": "contact-modal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Modal de contacto Bootstrap 5 con formulario AJAX para consultas de clientes",
|
||||||
|
"groups": {
|
||||||
|
"general": {
|
||||||
|
"label": "Configuración General",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"modal_title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título del modal",
|
||||||
|
"default": "¿Tienes alguna pregunta?",
|
||||||
|
"required": true,
|
||||||
|
"description": "Título que aparece en el encabezado del modal"
|
||||||
|
},
|
||||||
|
"modal_description": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Descripción",
|
||||||
|
"default": "Completa el formulario y nuestro equipo te responderá en menos de 24 horas.",
|
||||||
|
"description": "Texto descriptivo debajo del título"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form_fields": {
|
||||||
|
"label": "Campos del Formulario",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"fullName": {
|
||||||
|
"type": "object",
|
||||||
|
"label": "Campo Nombre Completo",
|
||||||
|
"default": {
|
||||||
|
"label": "Nombre completo",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": {"type": "text", "label": "Etiqueta"},
|
||||||
|
"placeholder": {"type": "text", "label": "Placeholder"},
|
||||||
|
"required": {"type": "boolean", "label": "Requerido"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"company": {
|
||||||
|
"type": "object",
|
||||||
|
"label": "Campo Empresa",
|
||||||
|
"default": {
|
||||||
|
"label": "Empresa",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": {"type": "text", "label": "Etiqueta"},
|
||||||
|
"placeholder": {"type": "text", "label": "Placeholder"},
|
||||||
|
"required": {"type": "boolean", "label": "Requerido"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"whatsapp": {
|
||||||
|
"type": "object",
|
||||||
|
"label": "Campo WhatsApp",
|
||||||
|
"default": {
|
||||||
|
"label": "WhatsApp",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": {"type": "text", "label": "Etiqueta"},
|
||||||
|
"placeholder": {"type": "text", "label": "Placeholder"},
|
||||||
|
"required": {"type": "boolean", "label": "Requerido"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "object",
|
||||||
|
"label": "Campo Email",
|
||||||
|
"default": {
|
||||||
|
"label": "Correo electrónico",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": {"type": "text", "label": "Etiqueta"},
|
||||||
|
"placeholder": {"type": "text", "label": "Placeholder"},
|
||||||
|
"required": {"type": "boolean", "label": "Requerido"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"type": "object",
|
||||||
|
"label": "Campo Comentarios",
|
||||||
|
"default": {
|
||||||
|
"label": "¿En qué podemos ayudarte?",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": false,
|
||||||
|
"rows": 4
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": {"type": "text", "label": "Etiqueta"},
|
||||||
|
"placeholder": {"type": "text", "label": "Placeholder"},
|
||||||
|
"required": {"type": "boolean", "label": "Requerido"},
|
||||||
|
"rows": {"type": "number", "label": "Filas"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit_button": {
|
||||||
|
"label": "Botón de Envío",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del botón",
|
||||||
|
"default": "Enviar Mensaje",
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto que aparece en el botón de envío"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Ícono del botón",
|
||||||
|
"default": "bi-send-fill",
|
||||||
|
"description": "Clase de Bootstrap Icons (ej: bi-send-fill)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"label": "Mensajes del Sistema",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"success": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Mensaje de éxito",
|
||||||
|
"default": "Mensaje enviado exitosamente. Te responderemos pronto.",
|
||||||
|
"description": "Mensaje cuando el formulario se envía correctamente"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Mensaje de error",
|
||||||
|
"default": "Error al enviar el mensaje. Por favor intenta nuevamente.",
|
||||||
|
"description": "Mensaje cuando ocurre un error"
|
||||||
|
},
|
||||||
|
"validation_error": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Mensaje de validación",
|
||||||
|
"default": "Por favor completa todos los campos requeridos.",
|
||||||
|
"description": "Mensaje cuando faltan campos obligatorios"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"label": "Configuración Avanzada",
|
||||||
|
"priority": 50,
|
||||||
|
"fields": {
|
||||||
|
"modal_id": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "ID del modal",
|
||||||
|
"default": "contactModal",
|
||||||
|
"readonly": true,
|
||||||
|
"description": "ID HTML del modal (no modificar)"
|
||||||
|
},
|
||||||
|
"form_id": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "ID del formulario",
|
||||||
|
"default": "modalContactForm",
|
||||||
|
"readonly": true,
|
||||||
|
"description": "ID HTML del formulario (no modificar)"
|
||||||
|
},
|
||||||
|
"ajax_action": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Acción AJAX",
|
||||||
|
"default": "roi_contact_modal_submit",
|
||||||
|
"readonly": true,
|
||||||
|
"description": "Nombre de la acción AJAX (no modificar)"
|
||||||
|
},
|
||||||
|
"email_to": {
|
||||||
|
"type": "email",
|
||||||
|
"label": "Email de destino",
|
||||||
|
"default": "",
|
||||||
|
"description": "Email donde se recibirán los mensajes (vacío = admin email)"
|
||||||
|
},
|
||||||
|
"email_subject": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Asunto del email",
|
||||||
|
"default": "Nuevo mensaje de contacto desde el sitio web",
|
||||||
|
"description": "Asunto del email que se enviará"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
schemas/components/cta-below-content.json
Normal file
208
schemas/components/cta-below-content.json
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"component_name": "cta-below-content",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Call to Action que se muestra debajo del contenido del post",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar CTA",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva el componente de Call to Action"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Layout del CTA",
|
||||||
|
"default": "two-column",
|
||||||
|
"options": {
|
||||||
|
"two-column": "Dos columnas (texto izquierda, botón derecha)",
|
||||||
|
"centered": "Centrado",
|
||||||
|
"stacked": "Apilado"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Distribución del contenido en el CTA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"label": "Contenido",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título del CTA",
|
||||||
|
"default": "Accede a 200,000+ Análisis de Precios Unitarios",
|
||||||
|
"maxlength": 200,
|
||||||
|
"required": true,
|
||||||
|
"description": "Título principal que aparece en el CTA"
|
||||||
|
},
|
||||||
|
"subtitle": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Subtítulo del CTA",
|
||||||
|
"default": "Consulta estructuras completas, insumos y dosificaciones de los APUs más utilizados en construcción en México.",
|
||||||
|
"maxlength": 500,
|
||||||
|
"rows": 3,
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto descriptivo que complementa el título"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"label": "Configuración del Botón",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"button_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del botón",
|
||||||
|
"default": "Ver Catálogo Completo",
|
||||||
|
"maxlength": 100,
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto que aparece en el botón de acción"
|
||||||
|
},
|
||||||
|
"button_url": {
|
||||||
|
"type": "url",
|
||||||
|
"label": "URL del botón",
|
||||||
|
"default": "#",
|
||||||
|
"required": true,
|
||||||
|
"description": "URL de destino al hacer clic en el botón"
|
||||||
|
},
|
||||||
|
"button_target": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Abrir enlace en",
|
||||||
|
"default": "_self",
|
||||||
|
"options": {
|
||||||
|
"_self": "Misma ventana",
|
||||||
|
"_blank": "Nueva ventana"
|
||||||
|
},
|
||||||
|
"description": "Atributo target del enlace del botón"
|
||||||
|
},
|
||||||
|
"button_color": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Color del botón",
|
||||||
|
"default": "light",
|
||||||
|
"options": {
|
||||||
|
"light": "Blanco",
|
||||||
|
"dark": "Negro",
|
||||||
|
"primary": "Azul",
|
||||||
|
"success": "Verde",
|
||||||
|
"danger": "Rojo",
|
||||||
|
"warning": "Amarillo"
|
||||||
|
},
|
||||||
|
"description": "Color de fondo del botón"
|
||||||
|
},
|
||||||
|
"button_size": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tamaño del botón",
|
||||||
|
"default": "lg",
|
||||||
|
"options": {
|
||||||
|
"sm": "Pequeño",
|
||||||
|
"md": "Mediano",
|
||||||
|
"lg": "Grande"
|
||||||
|
},
|
||||||
|
"description": "Tamaño del botón de acción"
|
||||||
|
},
|
||||||
|
"show_icon": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar icono en botón",
|
||||||
|
"default": true,
|
||||||
|
"description": "Muestra u oculta el icono de flecha en el botón"
|
||||||
|
},
|
||||||
|
"icon_class": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase del icono",
|
||||||
|
"default": "bi bi-arrow-right",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_icon",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Clase de Bootstrap Icons para el icono del botón"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos y Colores",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"gradient_start_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de inicio del gradiente",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color hexadecimal de inicio del gradiente"
|
||||||
|
},
|
||||||
|
"gradient_end_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fin del gradiente",
|
||||||
|
"default": "#FFB800",
|
||||||
|
"description": "Color hexadecimal de fin del gradiente"
|
||||||
|
},
|
||||||
|
"gradient_angle": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Ángulo del gradiente",
|
||||||
|
"default": 135,
|
||||||
|
"min": 0,
|
||||||
|
"max": 360,
|
||||||
|
"description": "Ángulo en grados del gradiente (0-360)"
|
||||||
|
},
|
||||||
|
"text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del texto del título y subtítulo"
|
||||||
|
},
|
||||||
|
"container_classes": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clases CSS del contenedor",
|
||||||
|
"default": "my-5 p-4 rounded cta-section",
|
||||||
|
"description": "Clases CSS adicionales para el contenedor principal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"label": "Configuración Avanzada",
|
||||||
|
"priority": 50,
|
||||||
|
"fields": {
|
||||||
|
"animation_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar animación de entrada",
|
||||||
|
"default": false,
|
||||||
|
"description": "Activa animaciones de entrada para hacer el CTA más llamativo"
|
||||||
|
},
|
||||||
|
"animation_type": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tipo de animación",
|
||||||
|
"default": "fade-in",
|
||||||
|
"options": {
|
||||||
|
"fade-in": "Fade In",
|
||||||
|
"slide-up": "Slide Up",
|
||||||
|
"scale": "Scale"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "animation_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Tipo de animación de entrada"
|
||||||
|
},
|
||||||
|
"animation_duration": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Duración de animación (ms)",
|
||||||
|
"default": 500,
|
||||||
|
"min": 100,
|
||||||
|
"max": 3000,
|
||||||
|
"step": 50,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "animation_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Duración de la animación en milisegundos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
schemas/components/cta-box-sidebar.json
Normal file
191
schemas/components/cta-box-sidebar.json
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
"component_name": "cta-box-sidebar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CTA Box para sidebar con llamado a la acción destacado",
|
||||||
|
"groups": {
|
||||||
|
"content": {
|
||||||
|
"label": "Contenido del CTA",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título",
|
||||||
|
"default": "¿Listo para potenciar tus proyectos?",
|
||||||
|
"maxlength": 100,
|
||||||
|
"required": true,
|
||||||
|
"description": "Título principal del CTA box"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Descripción",
|
||||||
|
"default": "Accede a nuestra biblioteca completa de APUs y herramientas profesionales.",
|
||||||
|
"maxlength": 200,
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto descriptivo del CTA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"label": "Configuración del Botón",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"button_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del botón",
|
||||||
|
"default": "Solicitar Demo",
|
||||||
|
"maxlength": 50,
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto que aparece en el botón CTA"
|
||||||
|
},
|
||||||
|
"button_icon": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Ícono del botón",
|
||||||
|
"default": "bi-calendar-check",
|
||||||
|
"description": "Clase de Bootstrap Icons (ej: bi-calendar-check)"
|
||||||
|
},
|
||||||
|
"button_action": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Acción del botón",
|
||||||
|
"default": "modal",
|
||||||
|
"options": {
|
||||||
|
"modal": "Abrir Modal",
|
||||||
|
"link": "Ir a URL",
|
||||||
|
"custom": "JavaScript Personalizado"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Tipo de acción al hacer clic"
|
||||||
|
},
|
||||||
|
"modal_target": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "ID del modal",
|
||||||
|
"default": "#contactModal",
|
||||||
|
"description": "ID del modal a abrir (si button_action es 'modal')"
|
||||||
|
},
|
||||||
|
"link_url": {
|
||||||
|
"type": "url",
|
||||||
|
"label": "URL de destino",
|
||||||
|
"default": "",
|
||||||
|
"description": "URL del enlace (si button_action es 'link')"
|
||||||
|
},
|
||||||
|
"link_target": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Abrir enlace en",
|
||||||
|
"default": "_self",
|
||||||
|
"options": {
|
||||||
|
"_self": "Misma pestaña",
|
||||||
|
"_blank": "Nueva pestaña"
|
||||||
|
},
|
||||||
|
"description": "Target del enlace"
|
||||||
|
},
|
||||||
|
"custom_onclick": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "JavaScript personalizado",
|
||||||
|
"default": "",
|
||||||
|
"description": "Código JavaScript para onclick (si button_action es 'custom')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"label": "Configuración General",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"height": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Altura del CTA box",
|
||||||
|
"default": "250px",
|
||||||
|
"description": "Altura del CTA box (CSS válido)"
|
||||||
|
},
|
||||||
|
"show_on_mobile": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar en móviles",
|
||||||
|
"default": true,
|
||||||
|
"description": "Mostrar en dispositivos móviles"
|
||||||
|
},
|
||||||
|
"custom_css_class": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase CSS personalizada",
|
||||||
|
"default": "",
|
||||||
|
"description": "Clase CSS adicional para el contenedor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos de Color",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"background_gradient": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Usar gradiente",
|
||||||
|
"default": false,
|
||||||
|
"description": "Usar gradiente en vez de color sólido"
|
||||||
|
},
|
||||||
|
"background_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color de fondo del CTA box"
|
||||||
|
},
|
||||||
|
"gradient_start": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color inicial del gradiente",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color inicial del gradiente"
|
||||||
|
},
|
||||||
|
"gradient_end": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color final del gradiente",
|
||||||
|
"default": "#FF6B00",
|
||||||
|
"description": "Color final del gradiente"
|
||||||
|
},
|
||||||
|
"title_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del título",
|
||||||
|
"default": "#ffffff",
|
||||||
|
"description": "Color del texto del título"
|
||||||
|
},
|
||||||
|
"description_color": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Color de la descripción",
|
||||||
|
"default": "rgba(255, 255, 255, 0.95)",
|
||||||
|
"description": "Color del texto de la descripción"
|
||||||
|
},
|
||||||
|
"button_bg_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo del botón",
|
||||||
|
"default": "#ffffff",
|
||||||
|
"description": "Color de fondo del botón"
|
||||||
|
},
|
||||||
|
"button_text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto del botón",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color del texto del botón"
|
||||||
|
},
|
||||||
|
"button_hover_bg": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo del botón (hover)",
|
||||||
|
"default": "#0E2337",
|
||||||
|
"description": "Color de fondo del botón al hover"
|
||||||
|
},
|
||||||
|
"button_hover_text": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto del botón (hover)",
|
||||||
|
"default": "#ffffff",
|
||||||
|
"description": "Color del texto del botón al hover"
|
||||||
|
},
|
||||||
|
"shadow_color": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Color de sombra",
|
||||||
|
"default": "rgba(255, 133, 0, 0.2)",
|
||||||
|
"description": "Color de la sombra"
|
||||||
|
},
|
||||||
|
"border_radius": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Radio del borde",
|
||||||
|
"default": "8px",
|
||||||
|
"description": "Radio del borde (CSS válido)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
schemas/components/footer.json
Normal file
220
schemas/components/footer.json
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
{
|
||||||
|
"component_name": "footer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Footer completo del sitio con 3 widgets, newsletter, copyright y redes sociales",
|
||||||
|
"groups": {
|
||||||
|
"widget_1": {
|
||||||
|
"label": "Widget 1",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar widget 1",
|
||||||
|
"default": true,
|
||||||
|
"description": "Mostrar u ocultar este widget"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título del widget",
|
||||||
|
"default": "Recursos",
|
||||||
|
"description": "Título de la columna del widget"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"type": "repeater",
|
||||||
|
"label": "Enlaces",
|
||||||
|
"description": "Lista de enlaces del widget",
|
||||||
|
"default": [
|
||||||
|
{"text": "Home", "url": "/"},
|
||||||
|
{"text": "Features", "url": "#features"},
|
||||||
|
{"text": "Pricing", "url": "#pricing"},
|
||||||
|
{"text": "FAQs", "url": "#faqs"},
|
||||||
|
{"text": "About", "url": "#about"}
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del enlace",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "URL",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget_2": {
|
||||||
|
"label": "Widget 2",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar widget 2",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título del widget",
|
||||||
|
"default": "Servicios"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"type": "repeater",
|
||||||
|
"label": "Enlaces",
|
||||||
|
"default": [
|
||||||
|
{"text": "Análisis", "url": "#analisis"},
|
||||||
|
{"text": "Presupuestos", "url": "#presupuestos"},
|
||||||
|
{"text": "Cotizaciones", "url": "#cotizaciones"},
|
||||||
|
{"text": "Proyectos", "url": "#proyectos"}
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"text": {"type": "text", "label": "Texto", "required": true},
|
||||||
|
"url": {"type": "text", "label": "URL", "required": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget_3": {
|
||||||
|
"label": "Widget 3",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar widget 3",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título del widget",
|
||||||
|
"default": "Empresa"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"type": "repeater",
|
||||||
|
"label": "Enlaces",
|
||||||
|
"default": [
|
||||||
|
{"text": "Acerca de", "url": "#acerca"},
|
||||||
|
{"text": "Blog", "url": "/blog"},
|
||||||
|
{"text": "Contacto", "url": "#contacto"},
|
||||||
|
{"text": "Política de Privacidad", "url": "/privacidad"}
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"text": {"type": "text", "label": "Texto", "required": true},
|
||||||
|
"url": {"type": "text", "label": "URL", "required": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"newsletter": {
|
||||||
|
"label": "Newsletter",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar newsletter",
|
||||||
|
"default": true,
|
||||||
|
"description": "Mostrar u ocultar sección de newsletter"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título",
|
||||||
|
"default": "Suscríbete a nuestro newsletter",
|
||||||
|
"required": true,
|
||||||
|
"description": "Título de la sección de newsletter"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Descripción",
|
||||||
|
"default": "Recibe actualizaciones mensuales sobre nuestros productos y servicios.",
|
||||||
|
"description": "Texto descriptivo debajo del título"
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Placeholder del email",
|
||||||
|
"default": "Correo electrónico",
|
||||||
|
"description": "Texto placeholder del campo de email"
|
||||||
|
},
|
||||||
|
"button_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del botón",
|
||||||
|
"default": "Suscribirse",
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto del botón de suscripción"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"copyright": {
|
||||||
|
"label": "Copyright",
|
||||||
|
"priority": 50,
|
||||||
|
"fields": {
|
||||||
|
"text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto de copyright",
|
||||||
|
"default": "ROI Theme. Todos los derechos reservados.",
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto que aparece después del año"
|
||||||
|
},
|
||||||
|
"year_auto": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Año automático",
|
||||||
|
"default": true,
|
||||||
|
"description": "Mostrar el año actual automáticamente"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"social_links": {
|
||||||
|
"label": "Redes Sociales",
|
||||||
|
"priority": 60,
|
||||||
|
"fields": {
|
||||||
|
"twitter": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Twitter URL",
|
||||||
|
"default": "",
|
||||||
|
"description": "URL completa de perfil de Twitter (deja vacío para ocultar)"
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Instagram URL",
|
||||||
|
"default": "",
|
||||||
|
"description": "URL completa de perfil de Instagram"
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Facebook URL",
|
||||||
|
"default": "",
|
||||||
|
"description": "URL completa de página de Facebook"
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "LinkedIn URL",
|
||||||
|
"default": "",
|
||||||
|
"description": "URL completa de perfil o página de LinkedIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos",
|
||||||
|
"priority": 70,
|
||||||
|
"fields": {
|
||||||
|
"background_color": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase de fondo",
|
||||||
|
"default": "bg-dark",
|
||||||
|
"description": "Clase de Bootstrap para el fondo (ej: bg-dark, bg-secondary)"
|
||||||
|
},
|
||||||
|
"text_color": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase de color de texto",
|
||||||
|
"default": "text-white",
|
||||||
|
"description": "Clase de Bootstrap para el color de texto"
|
||||||
|
},
|
||||||
|
"link_hover_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de enlaces al hover",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color de los enlaces cuando se pasa el mouse sobre ellos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
410
schemas/components/hero-section.json
Normal file
410
schemas/components/hero-section.json
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
{
|
||||||
|
"component_name": "hero-section",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Sección hero con badges de categorías y título H1 con gradiente",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar hero section",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva la sección hero"
|
||||||
|
},
|
||||||
|
"show_on_pages": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Mostrar en",
|
||||||
|
"default": "posts",
|
||||||
|
"options": {
|
||||||
|
"all": "Todas las páginas",
|
||||||
|
"home": "Solo página de inicio",
|
||||||
|
"posts": "Solo posts individuales",
|
||||||
|
"pages": "Solo páginas",
|
||||||
|
"custom": "Tipos de post específicos"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Define en qué páginas se mostrará la hero section"
|
||||||
|
},
|
||||||
|
"custom_post_types": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Tipos de post personalizados",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "Ej: post,page,producto",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_on_pages",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom"
|
||||||
|
},
|
||||||
|
"description": "Slugs de tipos de post separados por comas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"label": "Badges de Categorías",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"show_categories": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar badges de categorías",
|
||||||
|
"default": true,
|
||||||
|
"description": "Muestra badges con las categorías del post"
|
||||||
|
},
|
||||||
|
"categories_source": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Fuente de categorías",
|
||||||
|
"default": "post_categories",
|
||||||
|
"options": {
|
||||||
|
"post_categories": "Categorías del post",
|
||||||
|
"post_tags": "Etiquetas del post",
|
||||||
|
"custom_taxonomy": "Taxonomía personalizada",
|
||||||
|
"custom_list": "Lista personalizada"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_categories",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Define de dónde obtener las categorías"
|
||||||
|
},
|
||||||
|
"custom_taxonomy_name": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Nombre de taxonomía personalizada",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "Ej: project_category",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "categories_source",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom_taxonomy"
|
||||||
|
},
|
||||||
|
"description": "Slug de la taxonomía personalizada"
|
||||||
|
},
|
||||||
|
"custom_categories_list": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Lista personalizada de categorías",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "Análisis de Precios|#\nConstrucción|#\nMateriales|#",
|
||||||
|
"rows": 5,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "categories_source",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom_list"
|
||||||
|
},
|
||||||
|
"description": "Una categoría por línea en formato: Nombre|URL"
|
||||||
|
},
|
||||||
|
"max_categories": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Máximo de categorías a mostrar",
|
||||||
|
"default": 5,
|
||||||
|
"min": 1,
|
||||||
|
"max": 20,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_categories",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Número máximo de badges a mostrar"
|
||||||
|
},
|
||||||
|
"category_icon": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Ícono de categoría",
|
||||||
|
"default": "bi-folder-fill",
|
||||||
|
"placeholder": "Ej: bi-folder-fill",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_categories",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Clase del ícono Bootstrap para los badges"
|
||||||
|
},
|
||||||
|
"categories_alignment": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Alineación de categorías",
|
||||||
|
"default": "center",
|
||||||
|
"options": {
|
||||||
|
"left": "Izquierda",
|
||||||
|
"center": "Centro",
|
||||||
|
"right": "Derecha"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_categories",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Alineación de los badges de categorías"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"label": "Título Principal",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"title_source": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Fuente del título",
|
||||||
|
"default": "post_title",
|
||||||
|
"options": {
|
||||||
|
"post_title": "Título del post",
|
||||||
|
"custom_field": "Campo personalizado",
|
||||||
|
"custom_text": "Texto personalizado"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Define de dónde obtener el texto del título"
|
||||||
|
},
|
||||||
|
"custom_field_name": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Nombre del campo personalizado",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "Ej: hero_title",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "title_source",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom_field"
|
||||||
|
},
|
||||||
|
"description": "Nombre del custom field de WordPress"
|
||||||
|
},
|
||||||
|
"custom_text": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Texto personalizado",
|
||||||
|
"default": "",
|
||||||
|
"rows": 3,
|
||||||
|
"maxlength": 500,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "title_source",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom_text"
|
||||||
|
},
|
||||||
|
"description": "Texto personalizado para el título"
|
||||||
|
},
|
||||||
|
"title_tag": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Etiqueta HTML del título",
|
||||||
|
"default": "h1",
|
||||||
|
"options": {
|
||||||
|
"h1": "H1",
|
||||||
|
"h2": "H2",
|
||||||
|
"h3": "H3",
|
||||||
|
"div": "DIV"
|
||||||
|
},
|
||||||
|
"description": "Etiqueta HTML para el título"
|
||||||
|
},
|
||||||
|
"title_classes": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clases CSS adicionales",
|
||||||
|
"default": "display-5 fw-bold",
|
||||||
|
"placeholder": "Ej: display-5 fw-bold",
|
||||||
|
"description": "Clases CSS adicionales para el título"
|
||||||
|
},
|
||||||
|
"title_alignment": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Alineación del título",
|
||||||
|
"default": "center",
|
||||||
|
"options": {
|
||||||
|
"left": "Izquierda",
|
||||||
|
"center": "Centro",
|
||||||
|
"right": "Derecha"
|
||||||
|
},
|
||||||
|
"description": "Alineación del título"
|
||||||
|
},
|
||||||
|
"enable_gradient": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar gradiente en el texto",
|
||||||
|
"default": false,
|
||||||
|
"description": "Aplica efecto de gradiente al texto del título"
|
||||||
|
},
|
||||||
|
"gradient_color_start": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color inicial del gradiente",
|
||||||
|
"default": "#1e3a5f",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "enable_gradient",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Color de inicio del gradiente"
|
||||||
|
},
|
||||||
|
"gradient_color_end": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color final del gradiente",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "enable_gradient",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Color final del gradiente"
|
||||||
|
},
|
||||||
|
"gradient_direction": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Dirección del gradiente",
|
||||||
|
"default": "to-right",
|
||||||
|
"options": {
|
||||||
|
"to-right": "Izquierda a derecha",
|
||||||
|
"to-left": "Derecha a izquierda",
|
||||||
|
"to-bottom": "Arriba a abajo",
|
||||||
|
"to-top": "Abajo a arriba",
|
||||||
|
"diagonal": "Diagonal"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "enable_gradient",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Dirección del gradiente"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"background_type": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tipo de fondo",
|
||||||
|
"default": "gradient",
|
||||||
|
"options": {
|
||||||
|
"color": "Color sólido",
|
||||||
|
"gradient": "Gradiente",
|
||||||
|
"image": "Imagen",
|
||||||
|
"none": "Sin fondo"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Tipo de fondo para la hero section"
|
||||||
|
},
|
||||||
|
"background_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo",
|
||||||
|
"default": "#1e3a5f",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "color"
|
||||||
|
},
|
||||||
|
"description": "Color sólido de fondo"
|
||||||
|
},
|
||||||
|
"gradient_start_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color inicial del gradiente",
|
||||||
|
"default": "#1e3a5f",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "gradient"
|
||||||
|
},
|
||||||
|
"description": "Color de inicio del gradiente de fondo"
|
||||||
|
},
|
||||||
|
"gradient_end_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color final del gradiente",
|
||||||
|
"default": "#2c5282",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "gradient"
|
||||||
|
},
|
||||||
|
"description": "Color final del gradiente de fondo"
|
||||||
|
},
|
||||||
|
"gradient_angle": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Ángulo del gradiente (grados)",
|
||||||
|
"default": 135,
|
||||||
|
"min": 0,
|
||||||
|
"max": 360,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "gradient"
|
||||||
|
},
|
||||||
|
"description": "Ángulo del gradiente en grados (0-360)"
|
||||||
|
},
|
||||||
|
"background_image_url": {
|
||||||
|
"type": "media",
|
||||||
|
"label": "Imagen de fondo",
|
||||||
|
"default": "",
|
||||||
|
"media_type": "image",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "image"
|
||||||
|
},
|
||||||
|
"description": "Imagen de fondo para la hero section"
|
||||||
|
},
|
||||||
|
"background_overlay": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Overlay oscuro sobre imagen",
|
||||||
|
"default": true,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "image"
|
||||||
|
},
|
||||||
|
"description": "Agrega capa oscura sobre la imagen de fondo"
|
||||||
|
},
|
||||||
|
"overlay_opacity": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Opacidad del overlay (%)",
|
||||||
|
"default": 60,
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "background_overlay",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Opacidad de la capa oscura (0-100)"
|
||||||
|
},
|
||||||
|
"text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del texto del título y elementos"
|
||||||
|
},
|
||||||
|
"padding_vertical": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Padding vertical",
|
||||||
|
"default": "normal",
|
||||||
|
"options": {
|
||||||
|
"compact": "Compacto (2rem)",
|
||||||
|
"normal": "Normal (3rem)",
|
||||||
|
"spacious": "Espacioso (4rem)",
|
||||||
|
"extra-spacious": "Extra espacioso (5rem)"
|
||||||
|
},
|
||||||
|
"description": "Espaciado vertical de la sección"
|
||||||
|
},
|
||||||
|
"margin_bottom": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Margen inferior",
|
||||||
|
"default": "normal",
|
||||||
|
"options": {
|
||||||
|
"none": "Sin margen",
|
||||||
|
"small": "Pequeño (1rem)",
|
||||||
|
"normal": "Normal (1.5rem)",
|
||||||
|
"large": "Grande (2rem)"
|
||||||
|
},
|
||||||
|
"description": "Margen inferior de la sección"
|
||||||
|
},
|
||||||
|
"category_badge_background": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Fondo de badges",
|
||||||
|
"default": "rgba(255, 255, 255, 0.2)",
|
||||||
|
"description": "Color de fondo de los badges de categorías"
|
||||||
|
},
|
||||||
|
"category_badge_text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto de badges",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del texto en los badges de categorías"
|
||||||
|
},
|
||||||
|
"category_badge_blur": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Efecto blur en badges",
|
||||||
|
"default": true,
|
||||||
|
"description": "Aplica efecto de desenfoque (backdrop-filter) a los badges"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
421
schemas/components/navbar.json
Normal file
421
schemas/components/navbar.json
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
{
|
||||||
|
"component_name": "navbar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Barra de navegación principal con menú Bootstrap, logo y botón CTA",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar navbar",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva la barra de navegación"
|
||||||
|
},
|
||||||
|
"is_sticky": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Navbar fijo (sticky)",
|
||||||
|
"default": true,
|
||||||
|
"description": "Mantiene el navbar fijo al hacer scroll"
|
||||||
|
},
|
||||||
|
"hide_on_scroll": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Ocultar al hacer scroll hacia abajo",
|
||||||
|
"default": false,
|
||||||
|
"description": "Oculta el navbar cuando el usuario hace scroll hacia abajo"
|
||||||
|
},
|
||||||
|
"show_on_mobile": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar en dispositivos móviles",
|
||||||
|
"default": true,
|
||||||
|
"description": "Muestra el navbar en pantallas pequeñas con menú hamburguesa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"label": "Logo",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"logo_type": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tipo de logo",
|
||||||
|
"default": "image",
|
||||||
|
"options": {
|
||||||
|
"image": "Imagen",
|
||||||
|
"text": "Texto",
|
||||||
|
"none": "Sin logo"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Define el tipo de logo a mostrar"
|
||||||
|
},
|
||||||
|
"logo_image_url": {
|
||||||
|
"type": "media",
|
||||||
|
"label": "Imagen del logo",
|
||||||
|
"default": "",
|
||||||
|
"media_type": "image",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "logo_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "image"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Sube la imagen del logo (recomendado: PNG transparente)"
|
||||||
|
},
|
||||||
|
"logo_image_width": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Ancho del logo (px)",
|
||||||
|
"default": 150,
|
||||||
|
"min": 50,
|
||||||
|
"max": 400,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "logo_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "image"
|
||||||
|
},
|
||||||
|
"description": "Ancho del logo en píxeles"
|
||||||
|
},
|
||||||
|
"logo_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del logo",
|
||||||
|
"default": "",
|
||||||
|
"maxlength": 50,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "logo_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "text"
|
||||||
|
},
|
||||||
|
"description": "Texto a mostrar como logo"
|
||||||
|
},
|
||||||
|
"logo_link": {
|
||||||
|
"type": "url",
|
||||||
|
"label": "Enlace del logo",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "Dejar vacío para usar la URL del home",
|
||||||
|
"description": "URL de destino al hacer clic en el logo"
|
||||||
|
},
|
||||||
|
"logo_position": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Posición del logo",
|
||||||
|
"default": "left",
|
||||||
|
"options": {
|
||||||
|
"left": "Izquierda",
|
||||||
|
"center": "Centro",
|
||||||
|
"right": "Derecha"
|
||||||
|
},
|
||||||
|
"description": "Posición del logo en el navbar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"label": "Menú de Navegación",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"menu_location": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Ubicación del menú",
|
||||||
|
"default": "primary",
|
||||||
|
"options": {
|
||||||
|
"primary": "Menú Principal",
|
||||||
|
"secondary": "Menú Secundario",
|
||||||
|
"custom": "Menú personalizado"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Selecciona qué menú de WordPress mostrar"
|
||||||
|
},
|
||||||
|
"custom_menu_id": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "ID del menú personalizado",
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "menu_location",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom"
|
||||||
|
},
|
||||||
|
"description": "ID del menú personalizado de WordPress"
|
||||||
|
},
|
||||||
|
"menu_alignment": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Alineación del menú",
|
||||||
|
"default": "left",
|
||||||
|
"options": {
|
||||||
|
"left": "Izquierda",
|
||||||
|
"center": "Centro",
|
||||||
|
"right": "Derecha"
|
||||||
|
},
|
||||||
|
"description": "Alineación de los items del menú"
|
||||||
|
},
|
||||||
|
"enable_dropdowns": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Habilitar menús desplegables",
|
||||||
|
"default": true,
|
||||||
|
"description": "Permite submenús desplegables"
|
||||||
|
},
|
||||||
|
"dropdown_animation": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Animación de dropdowns",
|
||||||
|
"default": "fade",
|
||||||
|
"options": {
|
||||||
|
"none": "Sin animación",
|
||||||
|
"fade": "Aparecer gradualmente",
|
||||||
|
"slide": "Deslizar"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "enable_dropdowns",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Tipo de animación para los submenús"
|
||||||
|
},
|
||||||
|
"mobile_breakpoint": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Breakpoint para menú móvil",
|
||||||
|
"default": "lg",
|
||||||
|
"options": {
|
||||||
|
"sm": "Small (576px)",
|
||||||
|
"md": "Medium (768px)",
|
||||||
|
"lg": "Large (992px)",
|
||||||
|
"xl": "Extra Large (1200px)"
|
||||||
|
},
|
||||||
|
"description": "Punto de quiebre para mostrar menú hamburguesa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cta_button": {
|
||||||
|
"label": "Botón CTA (Call to Action)",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"button_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar botón CTA",
|
||||||
|
"default": true,
|
||||||
|
"description": "Activa o desactiva el botón de llamada a la acción"
|
||||||
|
},
|
||||||
|
"button_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del botón",
|
||||||
|
"default": "Let's Talk",
|
||||||
|
"maxlength": 30,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto que aparece en el botón"
|
||||||
|
},
|
||||||
|
"button_icon": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Ícono del botón",
|
||||||
|
"default": "bi-lightning-charge-fill",
|
||||||
|
"placeholder": "Ej: bi-lightning-charge-fill",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Clase del ícono Bootstrap (dejar vacío para sin ícono)"
|
||||||
|
},
|
||||||
|
"button_action_type": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tipo de acción del botón",
|
||||||
|
"default": "modal",
|
||||||
|
"options": {
|
||||||
|
"modal": "Abrir modal",
|
||||||
|
"link": "Ir a URL",
|
||||||
|
"scroll": "Scroll a sección"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Acción que se ejecuta al hacer clic"
|
||||||
|
},
|
||||||
|
"button_modal_target": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "ID del modal",
|
||||||
|
"default": "#contactModal",
|
||||||
|
"placeholder": "#contactModal",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_action_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "modal"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "ID del modal de Bootstrap a abrir (incluir #)"
|
||||||
|
},
|
||||||
|
"button_link_url": {
|
||||||
|
"type": "url",
|
||||||
|
"label": "URL del enlace",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "https://",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_action_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "link"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "URL de destino del botón"
|
||||||
|
},
|
||||||
|
"button_link_target": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Abrir enlace en",
|
||||||
|
"default": "_self",
|
||||||
|
"options": {
|
||||||
|
"_self": "Misma ventana",
|
||||||
|
"_blank": "Nueva ventana"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_action_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "link"
|
||||||
|
},
|
||||||
|
"description": "Destino del enlace"
|
||||||
|
},
|
||||||
|
"button_scroll_target": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "ID de la sección",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "#contact",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_action_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "scroll"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "ID de la sección a la que hacer scroll (incluir #)"
|
||||||
|
},
|
||||||
|
"button_position": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Posición del botón",
|
||||||
|
"default": "right",
|
||||||
|
"options": {
|
||||||
|
"left": "Antes del menú",
|
||||||
|
"right": "Después del menú"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "button_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Ubicación del botón en el navbar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos",
|
||||||
|
"priority": 50,
|
||||||
|
"fields": {
|
||||||
|
"background_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo",
|
||||||
|
"default": "#1e3a5f",
|
||||||
|
"description": "Color de fondo del navbar (por defecto: navy primary)"
|
||||||
|
},
|
||||||
|
"text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del texto de los links del menú"
|
||||||
|
},
|
||||||
|
"hover_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color hover",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color al pasar el mouse sobre los links (por defecto: orange primary)"
|
||||||
|
},
|
||||||
|
"active_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del item activo",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color del item de menú activo/actual"
|
||||||
|
},
|
||||||
|
"button_background": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo del botón",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color de fondo del botón CTA"
|
||||||
|
},
|
||||||
|
"button_text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto del botón",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del texto del botón CTA"
|
||||||
|
},
|
||||||
|
"button_hover_background": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color hover del botón",
|
||||||
|
"default": "#FF6B35",
|
||||||
|
"description": "Color de fondo del botón al hacer hover"
|
||||||
|
},
|
||||||
|
"padding_vertical": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Padding vertical",
|
||||||
|
"default": "normal",
|
||||||
|
"options": {
|
||||||
|
"compact": "Compacto (0.5rem)",
|
||||||
|
"normal": "Normal (1rem)",
|
||||||
|
"spacious": "Espacioso (1.5rem)"
|
||||||
|
},
|
||||||
|
"description": "Espaciado vertical del navbar"
|
||||||
|
},
|
||||||
|
"shadow_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar sombra",
|
||||||
|
"default": true,
|
||||||
|
"description": "Agrega sombra debajo del navbar"
|
||||||
|
},
|
||||||
|
"shadow_intensity": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Intensidad de la sombra",
|
||||||
|
"default": "medium",
|
||||||
|
"options": {
|
||||||
|
"light": "Ligera",
|
||||||
|
"medium": "Media",
|
||||||
|
"strong": "Fuerte"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "shadow_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Intensidad de la sombra del navbar"
|
||||||
|
},
|
||||||
|
"border_bottom_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Borde inferior",
|
||||||
|
"default": false,
|
||||||
|
"description": "Agrega un borde en la parte inferior del navbar"
|
||||||
|
},
|
||||||
|
"border_bottom_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del borde inferior",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "border_bottom_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Color del borde inferior"
|
||||||
|
},
|
||||||
|
"border_bottom_width": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Grosor del borde (px)",
|
||||||
|
"default": 3,
|
||||||
|
"min": 1,
|
||||||
|
"max": 10,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "border_bottom_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Grosor del borde inferior en píxeles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
schemas/components/related-posts.json
Normal file
145
schemas/components/related-posts.json
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"component_name": "related-posts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Posts relacionados al final del contenido principal",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar posts relacionados",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva el componente de posts relacionados"
|
||||||
|
},
|
||||||
|
"section_title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título de la sección",
|
||||||
|
"default": "Descubre Más Contenido",
|
||||||
|
"maxlength": 200,
|
||||||
|
"required": true,
|
||||||
|
"description": "Título que aparece antes del grid de posts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"label": "Configuración de Consulta",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"posts_per_page": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Posts por página",
|
||||||
|
"default": 12,
|
||||||
|
"min": 1,
|
||||||
|
"max": 100,
|
||||||
|
"required": true,
|
||||||
|
"description": "Cantidad de posts a mostrar por página"
|
||||||
|
},
|
||||||
|
"post_selection": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Criterio de selección",
|
||||||
|
"default": "category",
|
||||||
|
"options": {
|
||||||
|
"category": "Misma categoría",
|
||||||
|
"tags": "Mismos tags",
|
||||||
|
"both": "Categoría y tags",
|
||||||
|
"recent": "Más recientes",
|
||||||
|
"random": "Aleatorio"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Cómo seleccionar los posts relacionados"
|
||||||
|
},
|
||||||
|
"exclude_current_post": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Excluir post actual",
|
||||||
|
"default": true,
|
||||||
|
"description": "Excluye el post actual de los resultados"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"label": "Diseño y Disposición",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"columns": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Número de columnas",
|
||||||
|
"default": "3",
|
||||||
|
"options": {
|
||||||
|
"1": "1 columna",
|
||||||
|
"2": "2 columnas",
|
||||||
|
"3": "3 columnas",
|
||||||
|
"4": "4 columnas"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Número de columnas en el grid"
|
||||||
|
},
|
||||||
|
"card_height": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Altura de cards",
|
||||||
|
"default": "equal",
|
||||||
|
"options": {
|
||||||
|
"auto": "Automática",
|
||||||
|
"equal": "Igual (centrado)"
|
||||||
|
},
|
||||||
|
"description": "Controla cómo se muestran las cards"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"label": "Paginación",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"show_pagination": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar paginación",
|
||||||
|
"default": true,
|
||||||
|
"description": "Muestra u oculta la paginación"
|
||||||
|
},
|
||||||
|
"pagination_position": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Posición de la paginación",
|
||||||
|
"default": "center",
|
||||||
|
"options": {
|
||||||
|
"left": "Izquierda",
|
||||||
|
"center": "Centro",
|
||||||
|
"right": "Derecha"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_pagination",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Alineación de la paginación"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos",
|
||||||
|
"priority": 50,
|
||||||
|
"fields": {
|
||||||
|
"container_classes": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clases CSS del contenedor",
|
||||||
|
"default": "my-5 related-posts",
|
||||||
|
"description": "Clases CSS adicionales para el contenedor principal"
|
||||||
|
},
|
||||||
|
"grid_gap": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Espaciado del grid",
|
||||||
|
"default": "4",
|
||||||
|
"options": {
|
||||||
|
"1": "Muy pequeño",
|
||||||
|
"2": "Pequeño",
|
||||||
|
"3": "Normal",
|
||||||
|
"4": "Grande",
|
||||||
|
"5": "Muy grande"
|
||||||
|
},
|
||||||
|
"description": "Espaciado entre cards"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
schemas/components/share-buttons.json
Normal file
112
schemas/components/share-buttons.json
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"component_name": "share-buttons",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Botones para compartir contenido en redes sociales",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar botones de compartir",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva los botones de compartir en redes sociales"
|
||||||
|
},
|
||||||
|
"show_label": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar etiqueta",
|
||||||
|
"default": true,
|
||||||
|
"description": "Muestra u oculta el texto de etiqueta antes de los botones"
|
||||||
|
},
|
||||||
|
"label_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto de etiqueta",
|
||||||
|
"default": "Compartir:",
|
||||||
|
"maxlength": 100,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_label",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Texto que aparece antes de los botones"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"label": "Redes Sociales",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"enabled_networks": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Redes sociales habilitadas",
|
||||||
|
"default": ["facebook", "instagram", "linkedin", "whatsapp", "twitter", "email"],
|
||||||
|
"options": {
|
||||||
|
"facebook": "Facebook",
|
||||||
|
"instagram": "Instagram",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"whatsapp": "WhatsApp",
|
||||||
|
"twitter": "Twitter / X",
|
||||||
|
"email": "Email"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Lista de redes sociales que se mostrarán"
|
||||||
|
},
|
||||||
|
"show_network_labels": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar nombres de redes",
|
||||||
|
"default": false,
|
||||||
|
"description": "Muestra el nombre de la red social junto al icono"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"button_styles": {
|
||||||
|
"label": "Estilos de Botones",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"button_style": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Estilo de botones",
|
||||||
|
"default": "outline",
|
||||||
|
"options": {
|
||||||
|
"outline": "Outline (contorno)",
|
||||||
|
"solid": "Solid (relleno)"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Estilo visual de los botones"
|
||||||
|
},
|
||||||
|
"button_size": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tamaño de botones",
|
||||||
|
"default": "sm",
|
||||||
|
"options": {
|
||||||
|
"sm": "Pequeño",
|
||||||
|
"md": "Mediano",
|
||||||
|
"lg": "Grande"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Tamaño de los botones"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"label": "Configuración Avanzada",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"container_classes": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clases CSS del contenedor",
|
||||||
|
"default": "my-5 py-4 border-top",
|
||||||
|
"description": "Clases CSS adicionales para el contenedor principal"
|
||||||
|
},
|
||||||
|
"buttons_wrapper_classes": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clases CSS del wrapper de botones",
|
||||||
|
"default": "d-flex gap-2 flex-wrap share-buttons",
|
||||||
|
"description": "Clases CSS para el wrapper de los botones"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
schemas/components/table-of-contents.json
Normal file
171
schemas/components/table-of-contents.json
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"component_name": "table-of-contents",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Tabla de contenido con ScrollSpy para navegación lateral",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad y Comportamiento",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar tabla de contenido",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva el componente de tabla de contenido"
|
||||||
|
},
|
||||||
|
"sticky": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Posición sticky",
|
||||||
|
"default": true,
|
||||||
|
"description": "Mantiene el TOC visible al hacer scroll"
|
||||||
|
},
|
||||||
|
"show_on_mobile": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar en móviles",
|
||||||
|
"default": false,
|
||||||
|
"description": "Muestra el TOC en dispositivos móviles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"label": "Configuración General",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Título del TOC",
|
||||||
|
"default": "Tabla de Contenido",
|
||||||
|
"maxlength": 100,
|
||||||
|
"required": true,
|
||||||
|
"description": "Título que aparece en el encabezado del TOC"
|
||||||
|
},
|
||||||
|
"heading_levels": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Niveles de encabezados",
|
||||||
|
"default": ["h2", "h3"],
|
||||||
|
"options": {
|
||||||
|
"h2": "H2",
|
||||||
|
"h3": "H3",
|
||||||
|
"h4": "H4",
|
||||||
|
"h5": "H5",
|
||||||
|
"h6": "H6"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Niveles de encabezados a incluir en el TOC"
|
||||||
|
},
|
||||||
|
"auto_generate": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Generar automáticamente",
|
||||||
|
"default": true,
|
||||||
|
"description": "Generar TOC desde el contenido del post"
|
||||||
|
},
|
||||||
|
"offset_top": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Offset top (px)",
|
||||||
|
"default": 100,
|
||||||
|
"min": 0,
|
||||||
|
"max": 500,
|
||||||
|
"description": "Offset desde el top para el ScrollSpy"
|
||||||
|
},
|
||||||
|
"smooth_scroll": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Scroll suave",
|
||||||
|
"default": true,
|
||||||
|
"description": "Activar scroll suave al hacer clic en enlaces"
|
||||||
|
},
|
||||||
|
"max_height": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Altura máxima",
|
||||||
|
"default": "calc(100vh - 400px)",
|
||||||
|
"description": "Altura máxima del contenedor TOC (CSS válido)"
|
||||||
|
},
|
||||||
|
"custom_css_class": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase CSS personalizada",
|
||||||
|
"default": "",
|
||||||
|
"description": "Clase CSS adicional para el contenedor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manual_items": {
|
||||||
|
"label": "Items Manuales",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"items": {
|
||||||
|
"type": "repeater",
|
||||||
|
"label": "Items del TOC",
|
||||||
|
"default": [],
|
||||||
|
"description": "Items manuales si auto_generate es false",
|
||||||
|
"subfields": {
|
||||||
|
"text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del enlace",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"anchor": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "ID del ancla (sin #)",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"level": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Nivel (2-6)",
|
||||||
|
"default": 2,
|
||||||
|
"min": 2,
|
||||||
|
"max": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos Personalizados",
|
||||||
|
"priority": 40,
|
||||||
|
"fields": {
|
||||||
|
"background_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo",
|
||||||
|
"default": "#ffffff",
|
||||||
|
"description": "Color de fondo del contenedor"
|
||||||
|
},
|
||||||
|
"border_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de borde",
|
||||||
|
"default": "#E6E9ED",
|
||||||
|
"description": "Color del borde del contenedor"
|
||||||
|
},
|
||||||
|
"title_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del título",
|
||||||
|
"default": "#0E2337",
|
||||||
|
"description": "Color del texto del título"
|
||||||
|
},
|
||||||
|
"link_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de enlaces",
|
||||||
|
"default": "#6B7280",
|
||||||
|
"description": "Color de los enlaces del TOC"
|
||||||
|
},
|
||||||
|
"link_hover_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de enlaces (hover)",
|
||||||
|
"default": "#0E2337",
|
||||||
|
"description": "Color de los enlaces al pasar el mouse"
|
||||||
|
},
|
||||||
|
"active_border_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de borde activo",
|
||||||
|
"default": "#0E2337",
|
||||||
|
"description": "Color del borde izquierdo del item activo"
|
||||||
|
},
|
||||||
|
"active_bg_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo activo",
|
||||||
|
"default": "#F9FAFB",
|
||||||
|
"description": "Color de fondo del item activo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
schemas/components/top_notification_bar.json
Normal file
258
schemas/components/top_notification_bar.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"component_name": "top_notification_bar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Barra de notificación superior con anuncio destacado, ícono y enlace de acción",
|
||||||
|
"groups": {
|
||||||
|
"visibility": {
|
||||||
|
"label": "Visibilidad",
|
||||||
|
"priority": 10,
|
||||||
|
"fields": {
|
||||||
|
"is_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar barra de notificación",
|
||||||
|
"default": true,
|
||||||
|
"required": true,
|
||||||
|
"description": "Activa o desactiva la barra de notificación superior"
|
||||||
|
},
|
||||||
|
"show_on_pages": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Mostrar en",
|
||||||
|
"default": "all",
|
||||||
|
"options": {
|
||||||
|
"all": "Todas las páginas",
|
||||||
|
"home": "Solo página de inicio",
|
||||||
|
"posts": "Solo posts individuales",
|
||||||
|
"pages": "Solo páginas",
|
||||||
|
"custom": "Páginas específicas"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Define en qué páginas se mostrará la barra"
|
||||||
|
},
|
||||||
|
"custom_page_ids": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "IDs de páginas específicas",
|
||||||
|
"default": "",
|
||||||
|
"placeholder": "Ej: 1,5,10",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "show_on_pages",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom"
|
||||||
|
},
|
||||||
|
"description": "IDs de páginas separados por comas"
|
||||||
|
},
|
||||||
|
"hide_on_mobile": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Ocultar en dispositivos móviles",
|
||||||
|
"default": false,
|
||||||
|
"description": "Oculta la barra en pantallas menores a 768px"
|
||||||
|
},
|
||||||
|
"is_dismissible": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Permitir cerrar",
|
||||||
|
"default": false,
|
||||||
|
"description": "Agrega botón X para que el usuario pueda cerrar la barra"
|
||||||
|
},
|
||||||
|
"dismissible_cookie_days": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Días antes de volver a mostrar",
|
||||||
|
"default": 7,
|
||||||
|
"min": 1,
|
||||||
|
"max": 365,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "is_dismissible",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Días que permanece oculta después de cerrarla"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"label": "Contenido",
|
||||||
|
"priority": 20,
|
||||||
|
"fields": {
|
||||||
|
"icon_type": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tipo de ícono",
|
||||||
|
"default": "bootstrap",
|
||||||
|
"options": {
|
||||||
|
"bootstrap": "Bootstrap Icons",
|
||||||
|
"custom": "Imagen personalizada",
|
||||||
|
"none": "Sin ícono"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Selecciona el tipo de ícono a mostrar"
|
||||||
|
},
|
||||||
|
"bootstrap_icon": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Clase de ícono Bootstrap",
|
||||||
|
"default": "bi-megaphone-fill",
|
||||||
|
"placeholder": "Ej: bi-megaphone-fill",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "icon_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "bootstrap"
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Nombre de la clase del ícono sin el prefijo 'bi' (ej: megaphone-fill)"
|
||||||
|
},
|
||||||
|
"custom_icon_url": {
|
||||||
|
"type": "media",
|
||||||
|
"label": "Imagen personalizada",
|
||||||
|
"default": "",
|
||||||
|
"media_type": "image",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "icon_type",
|
||||||
|
"operator": "==",
|
||||||
|
"value": "custom"
|
||||||
|
},
|
||||||
|
"description": "Sube una imagen personalizada (recomendado: PNG 24x24px)"
|
||||||
|
},
|
||||||
|
"announcement_label": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Etiqueta del anuncio",
|
||||||
|
"default": "Nuevo:",
|
||||||
|
"placeholder": "Ej: Nuevo:, Importante:, Aviso:",
|
||||||
|
"maxlength": 30,
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto destacado en negrita antes del mensaje"
|
||||||
|
},
|
||||||
|
"announcement_text": {
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "Texto del anuncio",
|
||||||
|
"default": "Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.",
|
||||||
|
"maxlength": 200,
|
||||||
|
"rows": 3,
|
||||||
|
"required": true,
|
||||||
|
"description": "Mensaje principal del anuncio (máximo 200 caracteres)"
|
||||||
|
},
|
||||||
|
"link_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mostrar enlace",
|
||||||
|
"default": true,
|
||||||
|
"description": "Activa o desactiva el enlace de acción"
|
||||||
|
},
|
||||||
|
"link_text": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Texto del enlace",
|
||||||
|
"default": "Ver Catálogo",
|
||||||
|
"maxlength": 50,
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "link_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "Texto del enlace de acción"
|
||||||
|
},
|
||||||
|
"link_url": {
|
||||||
|
"type": "url",
|
||||||
|
"label": "URL del enlace",
|
||||||
|
"default": "#",
|
||||||
|
"placeholder": "https://",
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "link_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"description": "URL de destino del enlace"
|
||||||
|
},
|
||||||
|
"link_target": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Abrir enlace en",
|
||||||
|
"default": "_self",
|
||||||
|
"options": {
|
||||||
|
"_self": "Misma ventana",
|
||||||
|
"_blank": "Nueva ventana"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "link_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Define cómo se abrirá el enlace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"label": "Estilos",
|
||||||
|
"priority": 30,
|
||||||
|
"fields": {
|
||||||
|
"background_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color de fondo",
|
||||||
|
"default": "#FF8600",
|
||||||
|
"description": "Color de fondo de la barra (por defecto: orange primary)"
|
||||||
|
},
|
||||||
|
"text_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del texto",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del texto del anuncio"
|
||||||
|
},
|
||||||
|
"link_color": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Color del enlace",
|
||||||
|
"default": "#FFFFFF",
|
||||||
|
"description": "Color del enlace de acción"
|
||||||
|
},
|
||||||
|
"font_size": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tamaño de fuente",
|
||||||
|
"default": "small",
|
||||||
|
"options": {
|
||||||
|
"extra-small": "Muy pequeño (0.75rem)",
|
||||||
|
"small": "Pequeño (0.875rem)",
|
||||||
|
"normal": "Normal (1rem)",
|
||||||
|
"large": "Grande (1.125rem)"
|
||||||
|
},
|
||||||
|
"description": "Tamaño del texto del anuncio"
|
||||||
|
},
|
||||||
|
"padding_vertical": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Padding vertical",
|
||||||
|
"default": "normal",
|
||||||
|
"options": {
|
||||||
|
"compact": "Compacto (0.5rem)",
|
||||||
|
"normal": "Normal (0.75rem)",
|
||||||
|
"spacious": "Espacioso (1rem)"
|
||||||
|
},
|
||||||
|
"description": "Espaciado vertical interno de la barra"
|
||||||
|
},
|
||||||
|
"text_alignment": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Alineación del texto",
|
||||||
|
"default": "center",
|
||||||
|
"options": {
|
||||||
|
"left": "Izquierda",
|
||||||
|
"center": "Centro",
|
||||||
|
"right": "Derecha"
|
||||||
|
},
|
||||||
|
"description": "Alineación del contenido de la barra"
|
||||||
|
},
|
||||||
|
"animation_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Activar animación",
|
||||||
|
"default": false,
|
||||||
|
"description": "Activa animación de entrada al cargar la página"
|
||||||
|
},
|
||||||
|
"animation_type": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Tipo de animación",
|
||||||
|
"default": "slide-down",
|
||||||
|
"options": {
|
||||||
|
"slide-down": "Deslizar desde arriba",
|
||||||
|
"fade-in": "Aparecer gradualmente"
|
||||||
|
},
|
||||||
|
"conditional_logic": {
|
||||||
|
"field": "animation_enabled",
|
||||||
|
"operator": "==",
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"description": "Tipo de animación de entrada"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\CTABelowContent\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class CTABelowContentFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder cta-below-content-form">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#content-tab" type="button">Contenido</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#button-tab" type="button">Botón</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#advanced-tab" type="button">Avanzado</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="content-tab"><?php echo $this->buildContentTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="button-tab"><?php echo $this->buildButtonTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="advanced-tab"><?php echo $this->buildAdvancedTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilityTab(): string
|
||||||
|
{
|
||||||
|
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
|
||||||
|
$layout = $this->data['visibility']['layout'] ?? 'two-column';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="is_enabled"><strong>Activar CTA debajo del contenido</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Muestra u oculta el componente de Call to Action</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="layout"><strong>Layout del CTA</strong></label>
|
||||||
|
<select class="form-select" id="layout" name="visibility[layout]">
|
||||||
|
<option value="two-column" <?php selected($layout, 'two-column'); ?>>Dos columnas (texto izquierda, botón derecha)</option>
|
||||||
|
<option value="centered" <?php selected($layout, 'centered'); ?>>Centrado</option>
|
||||||
|
<option value="stacked" <?php selected($layout, 'stacked'); ?>>Apilado</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Distribución del contenido en el CTA</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContentTab(): string
|
||||||
|
{
|
||||||
|
$title = $this->data['content']['title'] ?? 'Accede a 200,000+ Análisis de Precios Unitarios';
|
||||||
|
$subtitle = $this->data['content']['subtitle'] ?? 'Consulta estructuras completas, insumos y dosificaciones de los APUs más utilizados en construcción en México.';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="title"><strong>Título</strong></label>
|
||||||
|
<input type="text" class="form-control" id="title" name="content[title]" value="<?php echo esc_attr($title); ?>" maxlength="200" required>
|
||||||
|
<small class="form-text text-muted">Título principal del CTA (máximo 200 caracteres)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="subtitle"><strong>Subtítulo</strong></label>
|
||||||
|
<textarea class="form-control" id="subtitle" name="content[subtitle]" rows="3" maxlength="500" required><?php echo esc_textarea($subtitle); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Texto descriptivo (máximo 500 caracteres)</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildButtonTab(): string
|
||||||
|
{
|
||||||
|
$buttonText = $this->data['button']['button_text'] ?? 'Ver Catálogo Completo';
|
||||||
|
$buttonUrl = $this->data['button']['button_url'] ?? '#';
|
||||||
|
$buttonTarget = $this->data['button']['button_target'] ?? '_self';
|
||||||
|
$buttonColor = $this->data['button']['button_color'] ?? 'light';
|
||||||
|
$buttonSize = $this->data['button']['button_size'] ?? 'lg';
|
||||||
|
$showIcon = $this->data['button']['show_icon'] ?? true;
|
||||||
|
$iconClass = $this->data['button']['icon_class'] ?? 'bi bi-arrow-right';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_text"><strong>Texto del botón</strong></label>
|
||||||
|
<input type="text" class="form-control" id="button_text" name="button[button_text]" value="<?php echo esc_attr($buttonText); ?>" maxlength="100" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_url"><strong>URL del botón</strong></label>
|
||||||
|
<input type="url" class="form-control" id="button_url" name="button[button_url]" value="<?php echo esc_url($buttonUrl); ?>" required>
|
||||||
|
<small class="form-text text-muted">URL de destino al hacer clic en el botón</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_target"><strong>Abrir enlace en</strong></label>
|
||||||
|
<select class="form-select" id="button_target" name="button[button_target]">
|
||||||
|
<option value="_self" <?php selected($buttonTarget, '_self'); ?>>Misma ventana</option>
|
||||||
|
<option value="_blank" <?php selected($buttonTarget, '_blank'); ?>>Nueva ventana</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_color"><strong>Color del botón</strong></label>
|
||||||
|
<select class="form-select" id="button_color" name="button[button_color]">
|
||||||
|
<option value="light" <?php selected($buttonColor, 'light'); ?>>Blanco</option>
|
||||||
|
<option value="dark" <?php selected($buttonColor, 'dark'); ?>>Negro</option>
|
||||||
|
<option value="primary" <?php selected($buttonColor, 'primary'); ?>>Azul</option>
|
||||||
|
<option value="success" <?php selected($buttonColor, 'success'); ?>>Verde</option>
|
||||||
|
<option value="danger" <?php selected($buttonColor, 'danger'); ?>>Rojo</option>
|
||||||
|
<option value="warning" <?php selected($buttonColor, 'warning'); ?>>Amarillo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_size"><strong>Tamaño del botón</strong></label>
|
||||||
|
<select class="form-select" id="button_size" name="button[button_size]">
|
||||||
|
<option value="sm" <?php selected($buttonSize, 'sm'); ?>>Pequeño</option>
|
||||||
|
<option value="md" <?php selected($buttonSize, 'md'); ?>>Mediano</option>
|
||||||
|
<option value="lg" <?php selected($buttonSize, 'lg'); ?>>Grande</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_icon" name="button[show_icon]" value="1" <?php checked($showIcon, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_icon">Mostrar icono de flecha en el botón</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4 conditional-field" data-depends-on="show_icon" data-condition="checked">
|
||||||
|
<label for="icon_class">Clase del icono (Bootstrap Icons)</label>
|
||||||
|
<input type="text" class="form-control" id="icon_class" name="button[icon_class]" value="<?php echo esc_attr($iconClass); ?>">
|
||||||
|
<small class="form-text text-muted">Ejemplo: bi bi-arrow-right, bi bi-chevron-right</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(): string
|
||||||
|
{
|
||||||
|
$gradientStart = $this->data['styles']['gradient_start_color'] ?? '#FF8600';
|
||||||
|
$gradientEnd = $this->data['styles']['gradient_end_color'] ?? '#FFB800';
|
||||||
|
$gradientAngle = $this->data['styles']['gradient_angle'] ?? 135;
|
||||||
|
$textColor = $this->data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
$containerClasses = $this->data['styles']['container_classes'] ?? 'my-5 p-4 rounded cta-section';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="gradient_start_color"><strong>Color de inicio del gradiente</strong></label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="color" class="form-control form-control-color" id="gradient_start_color" name="styles[gradient_start_color]" value="<?php echo esc_attr($gradientStart); ?>">
|
||||||
|
<input type="text" class="form-control" value="<?php echo esc_attr($gradientStart); ?>" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="gradient_end_color"><strong>Color de fin del gradiente</strong></label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="color" class="form-control form-control-color" id="gradient_end_color" name="styles[gradient_end_color]" value="<?php echo esc_attr($gradientEnd); ?>">
|
||||||
|
<input type="text" class="form-control" value="<?php echo esc_attr($gradientEnd); ?>" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="gradient_angle"><strong>Ángulo del gradiente</strong></label>
|
||||||
|
<input type="range" class="form-range" id="gradient_angle" name="styles[gradient_angle]" min="0" max="360" value="<?php echo esc_attr($gradientAngle); ?>" oninput="this.nextElementSibling.value = this.value + '°'">
|
||||||
|
<output><?php echo esc_html($gradientAngle); ?>°</output>
|
||||||
|
<small class="form-text text-muted d-block">Dirección del gradiente en grados (0-360)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="text_color"><strong>Color del texto</strong></label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="color" class="form-control form-control-color" id="text_color" name="styles[text_color]" value="<?php echo esc_attr($textColor); ?>">
|
||||||
|
<input type="text" class="form-control" value="<?php echo esc_attr($textColor); ?>" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="container_classes"><strong>Clases CSS del contenedor</strong></label>
|
||||||
|
<input type="text" class="form-control" id="container_classes" name="styles[container_classes]" value="<?php echo esc_attr($containerClasses); ?>">
|
||||||
|
<small class="form-text text-muted">Clases CSS adicionales para el contenedor principal</small>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Vista previa del gradiente:</strong>
|
||||||
|
<div style="background: linear-gradient(<?php echo esc_attr($gradientAngle); ?>deg, <?php echo esc_attr($gradientStart); ?> 0%, <?php echo esc_attr($gradientEnd); ?> 100%); padding: 20px; border-radius: 8px; margin-top: 10px; color: <?php echo esc_attr($textColor); ?>;">
|
||||||
|
Vista previa del gradiente con el color de texto seleccionado
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAdvancedTab(): string
|
||||||
|
{
|
||||||
|
$animationEnabled = $this->data['advanced']['animation_enabled'] ?? false;
|
||||||
|
$animationType = $this->data['advanced']['animation_type'] ?? 'fade-in';
|
||||||
|
$animationDuration = $this->data['advanced']['animation_duration'] ?? 500;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="animation_enabled" name="advanced[animation_enabled]" value="1" <?php checked($animationEnabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="animation_enabled"><strong>Activar animación de entrada</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Activa animaciones de entrada para hacer el CTA más llamativo</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4 conditional-field" data-depends-on="animation_enabled" data-condition="checked">
|
||||||
|
<label for="animation_type"><strong>Tipo de animación</strong></label>
|
||||||
|
<select class="form-select" id="animation_type" name="advanced[animation_type]">
|
||||||
|
<option value="fade-in" <?php selected($animationType, 'fade-in'); ?>>Fade In</option>
|
||||||
|
<option value="slide-up" <?php selected($animationType, 'slide-up'); ?>>Slide Up</option>
|
||||||
|
<option value="scale" <?php selected($animationType, 'scale'); ?>>Scale</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4 conditional-field" data-depends-on="animation_enabled" data-condition="checked">
|
||||||
|
<label for="animation_duration"><strong>Duración de animación (ms)</strong></label>
|
||||||
|
<input type="number" class="form-control" id="animation_duration" name="advanced[animation_duration]" value="<?php echo esc_attr($animationDuration); ?>" min="100" max="3000" step="50">
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>Notas:</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><strong>Layout:</strong> Elige cómo se distribuyen los elementos (dos columnas, centrado o apilado)</li>
|
||||||
|
<li><strong>Gradiente:</strong> Personaliza los colores y ángulo del gradiente de fondo</li>
|
||||||
|
<li><strong>Botón:</strong> Configura el texto, URL, color y tamaño del botón de acción</li>
|
||||||
|
<li><strong>Animación:</strong> Activa animaciones de entrada para hacer el CTA más llamativo</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\CTABelowContent\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class CTABelowContentRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$layout = $data['visibility']['layout'] ?? 'two-column';
|
||||||
|
$gradient = $this->buildGradientStyle($data);
|
||||||
|
$containerClasses = $data['styles']['container_classes'] ?? 'my-5 p-4 rounded cta-section';
|
||||||
|
|
||||||
|
$animationAttrs = '';
|
||||||
|
if ($this->isAnimationEnabled($data)) {
|
||||||
|
$animationAttrs = $this->getAnimationAttributes($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = sprintf(
|
||||||
|
'<div class="%s" style="background: %s;"%s>',
|
||||||
|
esc_attr($containerClasses),
|
||||||
|
esc_attr($gradient),
|
||||||
|
$animationAttrs
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= $this->renderLayout($data, $layout);
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
if ($this->isAnimationEnabled($data)) {
|
||||||
|
$html .= $this->renderAnimationScript($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAnimationEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['advanced']['animation_enabled']) &&
|
||||||
|
$data['advanced']['animation_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderLayout(array $data, string $layout): string
|
||||||
|
{
|
||||||
|
switch ($layout) {
|
||||||
|
case 'centered':
|
||||||
|
return $this->renderCenteredLayout($data);
|
||||||
|
case 'stacked':
|
||||||
|
return $this->renderStackedLayout($data);
|
||||||
|
case 'two-column':
|
||||||
|
default:
|
||||||
|
return $this->renderTwoColumnLayout($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTwoColumnLayout(array $data): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'<div class="row align-items-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-md-end mt-3 mt-md-0">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
</div>',
|
||||||
|
$this->renderTitle($data),
|
||||||
|
$this->renderSubtitle($data, 'mb-md-0'),
|
||||||
|
$this->renderButton($data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderCenteredLayout(array $data): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'<div class="text-center">
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
<div class="mt-4">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
</div>',
|
||||||
|
$this->renderTitle($data),
|
||||||
|
$this->renderSubtitle($data, 'mb-3'),
|
||||||
|
$this->renderButton($data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderStackedLayout(array $data): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'<div>
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
<div class="mt-3">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
</div>',
|
||||||
|
$this->renderTitle($data),
|
||||||
|
$this->renderSubtitle($data, 'mb-3'),
|
||||||
|
$this->renderButton($data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTitle(array $data): string
|
||||||
|
{
|
||||||
|
$title = $data['content']['title'] ?? 'Accede a 200,000+ Análisis de Precios Unitarios';
|
||||||
|
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<h3 class="h4 fw-bold mb-2" style="color: %s;">%s</h3>',
|
||||||
|
esc_attr($textColor),
|
||||||
|
esc_html($title)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderSubtitle(array $data, string $marginClass): string
|
||||||
|
{
|
||||||
|
$subtitle = $data['content']['subtitle'] ?? 'Consulta estructuras completas, insumos y dosificaciones de los APUs más utilizados en construcción en México.';
|
||||||
|
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<p class="%s" style="color: %s;">%s</p>',
|
||||||
|
esc_attr($marginClass),
|
||||||
|
esc_attr($textColor),
|
||||||
|
esc_html($subtitle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderButton(array $data): string
|
||||||
|
{
|
||||||
|
$buttonText = $data['button']['button_text'] ?? 'Ver Catálogo Completo';
|
||||||
|
$buttonUrl = $data['button']['button_url'] ?? '#';
|
||||||
|
$buttonColor = $data['button']['button_color'] ?? 'light';
|
||||||
|
$buttonSize = $data['button']['button_size'] ?? 'lg';
|
||||||
|
$buttonTarget = $data['button']['button_target'] ?? '_self';
|
||||||
|
$showIcon = $data['button']['show_icon'] ?? true;
|
||||||
|
$iconClass = $data['button']['icon_class'] ?? 'bi bi-arrow-right';
|
||||||
|
|
||||||
|
$buttonClass = sprintf('btn btn-%s btn-%s cta-button', $buttonColor, $buttonSize);
|
||||||
|
$rel = $buttonTarget === '_blank' ? ' rel="noopener noreferrer"' : '';
|
||||||
|
|
||||||
|
$iconHtml = '';
|
||||||
|
if ($showIcon) {
|
||||||
|
$iconHtml = sprintf(' <i class="%s ms-2"></i>', esc_attr($iconClass));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<a href="%s" class="%s" target="%s"%s>%s%s</a>',
|
||||||
|
esc_url($buttonUrl),
|
||||||
|
esc_attr($buttonClass),
|
||||||
|
esc_attr($buttonTarget),
|
||||||
|
$rel,
|
||||||
|
esc_html($buttonText),
|
||||||
|
$iconHtml
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildGradientStyle(array $data): string
|
||||||
|
{
|
||||||
|
$start = $data['styles']['gradient_start_color'] ?? '#FF8600';
|
||||||
|
$end = $data['styles']['gradient_end_color'] ?? '#FFB800';
|
||||||
|
$angle = $data['styles']['gradient_angle'] ?? 135;
|
||||||
|
|
||||||
|
return sprintf('linear-gradient(%ddeg, %s 0%%, %s 100%%)', $angle, $start, $end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAnimationAttributes(array $data): string
|
||||||
|
{
|
||||||
|
$type = $data['advanced']['animation_type'] ?? 'fade-in';
|
||||||
|
$duration = $data['advanced']['animation_duration'] ?? 500;
|
||||||
|
|
||||||
|
return sprintf(' data-animation="%s" data-animation-duration="%d"', esc_attr($type), (int)$duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderAnimationScript(array $data): string
|
||||||
|
{
|
||||||
|
$type = $data['advanced']['animation_type'] ?? 'fade-in';
|
||||||
|
|
||||||
|
return <<<SCRIPT
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const ctaElement = document.querySelector('.cta-section[data-animation]');
|
||||||
|
if (!ctaElement) return;
|
||||||
|
|
||||||
|
const animationType = ctaElement.getAttribute('data-animation');
|
||||||
|
const animationDuration = parseInt(ctaElement.getAttribute('data-animation-duration')) || 500;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
switch(animationType) {
|
||||||
|
case 'fade-in':
|
||||||
|
ctaElement.style.opacity = '0';
|
||||||
|
ctaElement.style.transition = `opacity \${animationDuration}ms ease`;
|
||||||
|
setTimeout(() => ctaElement.style.opacity = '1', 10);
|
||||||
|
break;
|
||||||
|
case 'slide-up':
|
||||||
|
ctaElement.style.transform = 'translateY(50px)';
|
||||||
|
ctaElement.style.opacity = '0';
|
||||||
|
ctaElement.style.transition = `transform \${animationDuration}ms ease, opacity \${animationDuration}ms ease`;
|
||||||
|
setTimeout(() => {
|
||||||
|
ctaElement.style.transform = 'translateY(0)';
|
||||||
|
ctaElement.style.opacity = '1';
|
||||||
|
}, 10);
|
||||||
|
break;
|
||||||
|
case 'scale':
|
||||||
|
ctaElement.style.transform = 'scale(0.9)';
|
||||||
|
ctaElement.style.opacity = '0';
|
||||||
|
ctaElement.style.transition = `transform \${animationDuration}ms ease, opacity \${animationDuration}ms ease`;
|
||||||
|
setTimeout(() => {
|
||||||
|
ctaElement.style.transform = 'scale(1)';
|
||||||
|
ctaElement.style.opacity = '1';
|
||||||
|
}, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
observer.unobserve(ctaElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
|
observer.observe(ctaElement);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'cta-below-content';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\CTABoxSidebar\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class CTABoxSidebarFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder cta-box-form">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#content-tab" type="button">Contenido</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#button-tab" type="button">Botón</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#config-tab" type="button">Configuración</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="content-tab"><?php echo $this->buildContentTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="button-tab"><?php echo $this->buildButtonTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="config-tab"><?php echo $this->buildConfigTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
jQuery(document).ready(function($) {
|
||||||
|
$('input[name="button[button_action]"]').on('change', function() {
|
||||||
|
$('.action-options').hide();
|
||||||
|
const action = $(this).val();
|
||||||
|
$('#' + action + '-options').show();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#button_icon').on('input', function() {
|
||||||
|
$('.icon-preview i').attr('class', 'bi ' + $(this).val());
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.icon-option').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const icon = $(this).data('icon');
|
||||||
|
$('#button_icon').val(icon).trigger('input');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#use-gradient').on('change', function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
$('#solid-color-group').hide();
|
||||||
|
$('#gradient-color-group').show();
|
||||||
|
} else {
|
||||||
|
$('#solid-color-group').show();
|
||||||
|
$('#gradient-color-group').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContentTab(): string
|
||||||
|
{
|
||||||
|
$title = $this->data['content']['title'] ?? '¿Listo para potenciar tus proyectos?';
|
||||||
|
$description = $this->data['content']['description'] ?? 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="title"><strong>Título</strong></label>
|
||||||
|
<input type="text" class="form-control" id="title" name="content[title]" value="<?php echo esc_attr($title); ?>" maxlength="100" required>
|
||||||
|
<small class="form-text text-muted">Título principal del CTA (máx. 100 caracteres)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="description"><strong>Descripción</strong></label>
|
||||||
|
<textarea class="form-control" id="description" name="content[description]" rows="3" maxlength="200" required><?php echo esc_textarea($description); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Texto descriptivo (máx. 200 caracteres)</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildButtonTab(): string
|
||||||
|
{
|
||||||
|
$buttonText = $this->data['button']['button_text'] ?? 'Solicitar Demo';
|
||||||
|
$buttonIcon = $this->data['button']['button_icon'] ?? 'bi-calendar-check';
|
||||||
|
$buttonAction = $this->data['button']['button_action'] ?? 'modal';
|
||||||
|
$modalTarget = $this->data['button']['modal_target'] ?? '#contactModal';
|
||||||
|
$linkUrl = $this->data['button']['link_url'] ?? '';
|
||||||
|
$linkTarget = $this->data['button']['link_target'] ?? '_self';
|
||||||
|
$customOnclick = $this->data['button']['custom_onclick'] ?? '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_text"><strong>Texto del botón</strong></label>
|
||||||
|
<input type="text" class="form-control" id="button_text" name="button[button_text]" value="<?php echo esc_attr($buttonText); ?>" maxlength="50" required>
|
||||||
|
<small class="form-text text-muted">Texto que aparece en el botón (máx. 50 caracteres)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_icon"><strong>Ícono del botón</strong></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="button_icon" name="button[button_icon]" value="<?php echo esc_attr($buttonIcon); ?>" placeholder="bi-calendar-check">
|
||||||
|
<div class="input-group-text icon-preview">
|
||||||
|
<i class="bi <?php echo esc_attr($buttonIcon); ?>"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Clase de Bootstrap Icons. <a href="https://icons.getbootstrap.com/" target="_blank">Ver íconos</a>
|
||||||
|
</small>
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong class="d-block mb-2">Íconos populares:</strong>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-calendar-check">
|
||||||
|
<i class="bi bi-calendar-check"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-rocket-takeoff">
|
||||||
|
<i class="bi bi-rocket-takeoff"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-gift">
|
||||||
|
<i class="bi bi-gift"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-star-fill">
|
||||||
|
<i class="bi bi-star-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary icon-option me-1" data-icon="bi-lightning-charge">
|
||||||
|
<i class="bi bi-lightning-charge"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label><strong>Acción del botón</strong></label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" id="action_modal" name="button[button_action]" value="modal" <?php checked($buttonAction, 'modal'); ?>>
|
||||||
|
<label class="form-check-label" for="action_modal">Abrir Modal</label>
|
||||||
|
</div>
|
||||||
|
<div id="modal-options" class="action-options mt-2 ms-4" style="display: <?php echo $buttonAction === 'modal' ? 'block' : 'none'; ?>;">
|
||||||
|
<label for="modal_target">ID del modal</label>
|
||||||
|
<input type="text" class="form-control" id="modal_target" name="button[modal_target]" value="<?php echo esc_attr($modalTarget); ?>" placeholder="#contactModal">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="radio" id="action_link" name="button[button_action]" value="link" <?php checked($buttonAction, 'link'); ?>>
|
||||||
|
<label class="form-check-label" for="action_link">Ir a URL</label>
|
||||||
|
</div>
|
||||||
|
<div id="link-options" class="action-options mt-2 ms-4" style="display: <?php echo $buttonAction === 'link' ? 'block' : 'none'; ?>;">
|
||||||
|
<label for="link_url">URL de destino</label>
|
||||||
|
<input type="url" class="form-control mb-2" id="link_url" name="button[link_url]" value="<?php echo esc_url($linkUrl); ?>" placeholder="https://example.com">
|
||||||
|
<label for="link_target">Abrir en</label>
|
||||||
|
<select class="form-select" id="link_target" name="button[link_target]">
|
||||||
|
<option value="_self" <?php selected($linkTarget, '_self'); ?>>Misma pestaña</option>
|
||||||
|
<option value="_blank" <?php selected($linkTarget, '_blank'); ?>>Nueva pestaña</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="radio" id="action_custom" name="button[button_action]" value="custom" <?php checked($buttonAction, 'custom'); ?>>
|
||||||
|
<label class="form-check-label" for="action_custom">JavaScript Personalizado</label>
|
||||||
|
</div>
|
||||||
|
<div id="custom-options" class="action-options mt-2 ms-4" style="display: <?php echo $buttonAction === 'custom' ? 'block' : 'none'; ?>;">
|
||||||
|
<label for="custom_onclick">Código JavaScript</label>
|
||||||
|
<textarea class="form-control" id="custom_onclick" name="button[custom_onclick]" rows="3" placeholder="alert('Hola mundo!');"><?php echo esc_textarea($customOnclick); ?></textarea>
|
||||||
|
<small class="form-text text-muted text-warning">Solo administradores pueden usar JavaScript personalizado</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildConfigTab(): string
|
||||||
|
{
|
||||||
|
$height = $this->data['config']['height'] ?? '250px';
|
||||||
|
$showOnMobile = $this->data['config']['show_on_mobile'] ?? true;
|
||||||
|
$customCssClass = $this->data['config']['custom_css_class'] ?? '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="height"><strong>Altura del CTA box</strong></label>
|
||||||
|
<input type="text" class="form-control" id="height" name="config[height]" value="<?php echo esc_attr($height); ?>">
|
||||||
|
<small class="form-text text-muted">Valor CSS válido (ej: 250px, 20rem)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_on_mobile" name="config[show_on_mobile]" value="1" <?php checked($showOnMobile, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_on_mobile">Mostrar en dispositivos móviles</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="custom_css_class"><strong>Clase CSS personalizada</strong></label>
|
||||||
|
<input type="text" class="form-control" id="custom_css_class" name="config[custom_css_class]" value="<?php echo esc_attr($customCssClass); ?>">
|
||||||
|
<small class="form-text text-muted">Opcional: clase CSS adicional</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(): string
|
||||||
|
{
|
||||||
|
$backgroundGradient = $this->data['styles']['background_gradient'] ?? false;
|
||||||
|
$backgroundColor = $this->data['styles']['background_color'] ?? '#FF8600';
|
||||||
|
$gradientStart = $this->data['styles']['gradient_start'] ?? '#FF8600';
|
||||||
|
$gradientEnd = $this->data['styles']['gradient_end'] ?? '#FF6B00';
|
||||||
|
$titleColor = $this->data['styles']['title_color'] ?? '#ffffff';
|
||||||
|
$descriptionColor = $this->data['styles']['description_color'] ?? 'rgba(255, 255, 255, 0.95)';
|
||||||
|
$buttonBgColor = $this->data['styles']['button_bg_color'] ?? '#ffffff';
|
||||||
|
$buttonTextColor = $this->data['styles']['button_text_color'] ?? '#FF8600';
|
||||||
|
$buttonHoverBg = $this->data['styles']['button_hover_bg'] ?? '#0E2337';
|
||||||
|
$buttonHoverText = $this->data['styles']['button_hover_text'] ?? '#ffffff';
|
||||||
|
$shadowColor = $this->data['styles']['shadow_color'] ?? 'rgba(255, 133, 0, 0.2)';
|
||||||
|
$borderRadius = $this->data['styles']['border_radius'] ?? '8px';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="use-gradient" name="styles[background_gradient]" value="1" <?php checked($backgroundGradient, true); ?>>
|
||||||
|
<label class="form-check-label" for="use-gradient">Usar gradiente en lugar de color sólido</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="solid-color-group" class="mb-4" style="display: <?php echo $backgroundGradient ? 'none' : 'block'; ?>;">
|
||||||
|
<label for="background_color"><strong>Color de fondo</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($backgroundColor); ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gradient-color-group" class="mb-4" style="display: <?php echo $backgroundGradient ? 'block' : 'none'; ?>;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="gradient_start"><strong>Color inicial del gradiente</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="gradient_start" name="styles[gradient_start]" value="<?php echo esc_attr($gradientStart); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="gradient_end"><strong>Color final del gradiente</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="gradient_end" name="styles[gradient_end]" value="<?php echo esc_attr($gradientEnd); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="title_color"><strong>Color del título</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="title_color" name="styles[title_color]" value="<?php echo esc_attr($titleColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="description_color"><strong>Color de la descripción</strong></label>
|
||||||
|
<input type="text" class="form-control" id="description_color" name="styles[description_color]" value="<?php echo esc_attr($descriptionColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="button_bg_color"><strong>Color de fondo del botón</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="button_bg_color" name="styles[button_bg_color]" value="<?php echo esc_attr($buttonBgColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="button_text_color"><strong>Color del texto del botón</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="button_text_color" name="styles[button_text_color]" value="<?php echo esc_attr($buttonTextColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="button_hover_bg"><strong>Color de fondo (hover)</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="button_hover_bg" name="styles[button_hover_bg]" value="<?php echo esc_attr($buttonHoverBg); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="button_hover_text"><strong>Color del texto (hover)</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="button_hover_text" name="styles[button_hover_text]" value="<?php echo esc_attr($buttonHoverText); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="shadow_color"><strong>Color de sombra</strong></label>
|
||||||
|
<input type="text" class="form-control" id="shadow_color" name="styles[shadow_color]" value="<?php echo esc_attr($shadowColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="border_radius"><strong>Radio del borde</strong></label>
|
||||||
|
<input type="text" class="form-control" id="border_radius" name="styles[border_radius]" value="<?php echo esc_attr($borderRadius); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\CTABoxSidebar\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class CTABoxSidebarRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
$title = $data['content']['title'] ?? '¿Listo para potenciar tus proyectos?';
|
||||||
|
$description = $data['content']['description'] ?? 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.';
|
||||||
|
$buttonText = $data['button']['button_text'] ?? 'Solicitar Demo';
|
||||||
|
$buttonIcon = $data['button']['button_icon'] ?? 'bi-calendar-check';
|
||||||
|
$buttonAction = $data['button']['button_action'] ?? 'modal';
|
||||||
|
$modalTarget = $data['button']['modal_target'] ?? '#contactModal';
|
||||||
|
$linkUrl = $data['button']['link_url'] ?? '#';
|
||||||
|
$linkTarget = $data['button']['link_target'] ?? '_self';
|
||||||
|
$customOnclick = $data['button']['custom_onclick'] ?? '';
|
||||||
|
$height = $data['config']['height'] ?? '250px';
|
||||||
|
$showOnMobile = $data['config']['show_on_mobile'] ?? true;
|
||||||
|
$customCssClass = $data['config']['custom_css_class'] ?? '';
|
||||||
|
|
||||||
|
$componentId = $component->getId();
|
||||||
|
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
|
||||||
|
|
||||||
|
$mobileClass = !$showOnMobile ? 'd-none d-lg-block' : '';
|
||||||
|
$buttonAttrs = $this->buildButtonAttributes($buttonAction, $modalTarget, $customOnclick);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<?php if (!empty($customStyles)): ?>
|
||||||
|
<style>
|
||||||
|
<?php echo $customStyles; ?>
|
||||||
|
</style>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div id="<?php echo esc_attr($componentId); ?>"
|
||||||
|
class="cta-box-sidebar <?php echo esc_attr($customCssClass . ' ' . $mobileClass); ?>"
|
||||||
|
style="height: <?php echo esc_attr($height); ?>;">
|
||||||
|
|
||||||
|
<h5 class="cta-box-title">
|
||||||
|
<?php echo esc_html($title); ?>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<p class="cta-box-text">
|
||||||
|
<?php echo esc_html($description); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if ($buttonAction === 'link'): ?>
|
||||||
|
<a href="<?php echo esc_url($linkUrl); ?>"
|
||||||
|
target="<?php echo esc_attr($linkTarget); ?>"
|
||||||
|
class="btn btn-cta-box w-100"
|
||||||
|
<?php if ($linkTarget === '_blank'): ?>
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
<?php endif; ?>>
|
||||||
|
<?php if ($buttonIcon): ?>
|
||||||
|
<i class="bi <?php echo esc_attr($buttonIcon); ?> me-2"></i>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php echo esc_html($buttonText); ?>
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-cta-box w-100"
|
||||||
|
<?php echo $buttonAttrs; ?>>
|
||||||
|
<?php if ($buttonIcon): ?>
|
||||||
|
<i class="bi <?php echo esc_attr($buttonIcon); ?> me-2"></i>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php echo esc_html($buttonText); ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildButtonAttributes(string $action, string $modalTarget, string $customOnclick): string
|
||||||
|
{
|
||||||
|
$attrs = [];
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'modal':
|
||||||
|
$attrs[] = 'data-bs-toggle="modal"';
|
||||||
|
$attrs[] = 'data-bs-target="' . esc_attr($modalTarget) . '"';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
if (!empty($customOnclick)) {
|
||||||
|
$attrs[] = 'onclick="' . esc_attr($customOnclick) . '"';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCustomStyles(string $componentId, array $styles): string
|
||||||
|
{
|
||||||
|
if (empty($styles)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$css = [];
|
||||||
|
|
||||||
|
if (isset($styles['background_gradient']) && $styles['background_gradient']) {
|
||||||
|
$gradientStart = $styles['gradient_start'] ?? '#FF8600';
|
||||||
|
$gradientEnd = $styles['gradient_end'] ?? '#FF6B00';
|
||||||
|
$css[] = "#$componentId.cta-box-sidebar { background: linear-gradient(135deg, {$gradientStart} 0%, {$gradientEnd} 100%); }";
|
||||||
|
} elseif (isset($styles['background_color'])) {
|
||||||
|
$css[] = "#$componentId.cta-box-sidebar { background-color: {$styles['background_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['shadow_color'])) {
|
||||||
|
$css[] = "#$componentId.cta-box-sidebar { box-shadow: 0 4px 12px {$styles['shadow_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['border_radius'])) {
|
||||||
|
$css[] = "#$componentId.cta-box-sidebar { border-radius: {$styles['border_radius']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['title_color'])) {
|
||||||
|
$css[] = "#$componentId .cta-box-title { color: {$styles['title_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['description_color'])) {
|
||||||
|
$css[] = "#$componentId .cta-box-text { color: {$styles['description_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['button_bg_color'])) {
|
||||||
|
$css[] = "#$componentId .btn-cta-box { background-color: {$styles['button_bg_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['button_text_color'])) {
|
||||||
|
$css[] = "#$componentId .btn-cta-box { color: {$styles['button_text_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['button_hover_bg'])) {
|
||||||
|
$css[] = "#$componentId .btn-cta-box:hover { background-color: {$styles['button_hover_bg']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['button_hover_text'])) {
|
||||||
|
$css[] = "#$componentId .btn-cta-box:hover { color: {$styles['button_hover_text']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'cta-box-sidebar';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache Service Interface
|
||||||
|
*
|
||||||
|
* Defines the contract for caching services.
|
||||||
|
* Implementations will provide caching mechanisms (transients, object cache, etc).
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\Contracts
|
||||||
|
*/
|
||||||
|
interface CacheServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get cached value
|
||||||
|
*
|
||||||
|
* @param string $key Cache key
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function get(string $key): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cached value
|
||||||
|
*
|
||||||
|
* @param string $key Cache key
|
||||||
|
* @param mixed $value Value to cache
|
||||||
|
* @param int $expiration Expiration time in seconds
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function set(string $key, mixed $value, int $expiration = 0): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached value
|
||||||
|
*
|
||||||
|
* @param string $key Cache key
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function delete(string $key): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all cache
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function flush(): bool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation Service Interface
|
||||||
|
*
|
||||||
|
* Defines the contract for validation services.
|
||||||
|
* Implementations will provide validation logic for various data types.
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\Contracts
|
||||||
|
*/
|
||||||
|
interface ValidationServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate data against rules
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Data to validate
|
||||||
|
* @param array<string, mixed> $rules Validation rules
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function validate(array $data, array $rules): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validation errors
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getErrors(): array;
|
||||||
|
}
|
||||||
0
src/Component/Application/DTO/.gitkeep
Normal file
0
src/Component/Application/DTO/.gitkeep
Normal file
110
src/Component/Application/README.md
Normal file
110
src/Component/Application/README.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Capa de Aplicación
|
||||||
|
|
||||||
|
## 📋 Propósito
|
||||||
|
|
||||||
|
La **Capa de Aplicación** contiene los **Use Cases** (Casos de Uso) que orquestan la lógica de negocio de la aplicación. Actúa como punto de entrada coordinando el flujo entre la UI/API y la Capa de Dominio.
|
||||||
|
|
||||||
|
## 🎯 Responsabilidades
|
||||||
|
|
||||||
|
- ✅ Orquestar flujo de la aplicación
|
||||||
|
- ✅ Coordinar llamadas entre capas
|
||||||
|
- ✅ Transformar datos (DTOs)
|
||||||
|
- ✅ Manejar transacciones
|
||||||
|
- ❌ NO contiene lógica de negocio (eso es del Dominio)
|
||||||
|
- ❌ NO conoce detalles de implementación (solo interfaces)
|
||||||
|
|
||||||
|
## 📦 Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
Application/
|
||||||
|
├── UseCases/
|
||||||
|
│ ├── SaveComponent/
|
||||||
|
│ │ ├── SaveComponentUseCase.php (Orquestador)
|
||||||
|
│ │ ├── SaveComponentRequest.php (DTO entrada)
|
||||||
|
│ │ └── SaveComponentResponse.php (DTO salida)
|
||||||
|
│ ├── GetComponent/
|
||||||
|
│ ├── SyncSchema/
|
||||||
|
│ └── DeleteComponent/
|
||||||
|
└── README.md (este archivo)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Use Cases Disponibles
|
||||||
|
|
||||||
|
### 1. SaveComponent
|
||||||
|
Guardar configuración de un componente.
|
||||||
|
|
||||||
|
**Flujo**:
|
||||||
|
1. Validar datos contra schema
|
||||||
|
2. Sanitizar datos
|
||||||
|
3. Crear entidad de dominio
|
||||||
|
4. Persistir
|
||||||
|
5. Invalidar cache
|
||||||
|
|
||||||
|
**Uso**:
|
||||||
|
```php
|
||||||
|
$useCase = new SaveComponentUseCase($repository, $validator, $cache);
|
||||||
|
$request = new SaveComponentRequest('top_bar', $data);
|
||||||
|
$response = $useCase->execute($request);
|
||||||
|
|
||||||
|
if ($response->isSuccess()) {
|
||||||
|
// Éxito
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GetComponent
|
||||||
|
Obtener configuración de un componente (con cache).
|
||||||
|
|
||||||
|
**Estrategia**: Cache-first (1 hora TTL)
|
||||||
|
|
||||||
|
### 3. SyncSchema
|
||||||
|
Sincronizar schemas desde JSON a BD.
|
||||||
|
|
||||||
|
**Operaciones**: Agregar, Actualizar, Eliminar componentes
|
||||||
|
|
||||||
|
### 4. DeleteComponent
|
||||||
|
Eliminar componente y su cache.
|
||||||
|
|
||||||
|
## 📐 Principios Arquitectónicos
|
||||||
|
|
||||||
|
### Regla de Dependencias
|
||||||
|
|
||||||
|
```
|
||||||
|
Application → Domain (Interfaces)
|
||||||
|
↑
|
||||||
|
NO depende de Infrastructure
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Inversion
|
||||||
|
|
||||||
|
Los Use Cases dependen de **interfaces** (Domain), no implementaciones (Infrastructure):
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ CORRECTO
|
||||||
|
public function __construct(
|
||||||
|
private ComponentRepositoryInterface $repository // Interfaz
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ❌ INCORRECTO
|
||||||
|
public function __construct(
|
||||||
|
private WordPressComponentRepository $repository // Implementación concreta
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
Todos los Use Cases tienen tests unitarios usando **mocks** para dependencias:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$repository = $this->createMock(ComponentRepositoryInterface::class);
|
||||||
|
$validator = $this->createMock(ValidationServiceInterface::class);
|
||||||
|
|
||||||
|
$useCase = new SaveComponentUseCase($repository, $validator, $cache);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cobertura esperada**: 90%+
|
||||||
|
|
||||||
|
## 🔗 Referencias
|
||||||
|
|
||||||
|
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||||
|
- Domain Layer: `../Domain/README.md`
|
||||||
|
- Tests: `../../tests/Unit/Application/`
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\DeleteComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteComponentRequest - DTO para eliminar componente
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\DeleteComponent
|
||||||
|
*/
|
||||||
|
final class DeleteComponentRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $componentName
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getComponentName(): string
|
||||||
|
{
|
||||||
|
return $this->componentName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\DeleteComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteComponentResponse - DTO de respuesta para eliminación
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\DeleteComponent
|
||||||
|
*/
|
||||||
|
final class DeleteComponentResponse
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
private bool $success,
|
||||||
|
private ?string $message,
|
||||||
|
private ?string $error
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): ?string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getError(): ?string
|
||||||
|
{
|
||||||
|
return $this->error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function success(string $message): self
|
||||||
|
{
|
||||||
|
return new self(true, $message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function failure(string $error): self
|
||||||
|
{
|
||||||
|
return new self(false, null, $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => $this->success,
|
||||||
|
'message' => $this->message,
|
||||||
|
'error' => $this->error
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\DeleteComponent;
|
||||||
|
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteComponentUseCase - Eliminar componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Orquestar eliminación de componente
|
||||||
|
*
|
||||||
|
* FLUJO:
|
||||||
|
* 1. Verificar que existe
|
||||||
|
* 2. Eliminar de BD
|
||||||
|
* 3. Invalidar cache
|
||||||
|
* 4. Retornar confirmación
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\DeleteComponent
|
||||||
|
*/
|
||||||
|
final class DeleteComponentUseCase
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ComponentRepositoryInterface $repository,
|
||||||
|
private CacheServiceInterface $cache
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function execute(DeleteComponentRequest $request): DeleteComponentResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$componentNameString = $request->getComponentName();
|
||||||
|
$componentName = new ComponentName($componentNameString);
|
||||||
|
|
||||||
|
// 1. Verificar que existe
|
||||||
|
$component = $this->repository->findByName($componentName);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
return DeleteComponentResponse::failure(
|
||||||
|
"Component '{$componentNameString}' not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Eliminar
|
||||||
|
$deleted = $this->repository->delete($componentName);
|
||||||
|
|
||||||
|
if (!$deleted) {
|
||||||
|
return DeleteComponentResponse::failure(
|
||||||
|
"Failed to delete component '{$componentNameString}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Invalidar cache
|
||||||
|
$this->cache->delete("component_{$componentNameString}");
|
||||||
|
|
||||||
|
// 4. Retornar éxito
|
||||||
|
return DeleteComponentResponse::success(
|
||||||
|
"Component '{$componentNameString}' deleted successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return DeleteComponentResponse::failure(
|
||||||
|
'Unexpected error: ' . $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\GetComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetComponentRequest - DTO de entrada para obtener componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular el nombre del componente a obtener
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $request = new GetComponentRequest('top_bar');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\GetComponent
|
||||||
|
*/
|
||||||
|
final class GetComponentRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $componentName Nombre del componente a obtener
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private string $componentName
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener nombre del componente
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getComponentName(): string
|
||||||
|
{
|
||||||
|
return $this->componentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Crear desde string
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromString(string $componentName): self
|
||||||
|
{
|
||||||
|
return new self($componentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\GetComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetComponentResponse - DTO de salida para obtener componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular resultado de obtener un componente
|
||||||
|
*
|
||||||
|
* PATRÓN: Success/Failure
|
||||||
|
* - Éxito: success=true, data contiene el componente
|
||||||
|
* - Fallo: success=false, error contiene mensaje de error
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $response = GetComponentResponse::success($componentData);
|
||||||
|
* if ($response->isSuccess()) {
|
||||||
|
* $data = $response->getData();
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\GetComponent
|
||||||
|
*/
|
||||||
|
final class GetComponentResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor privado - usar factory methods
|
||||||
|
*
|
||||||
|
* @param bool $success Indica si la operación fue exitosa
|
||||||
|
* @param mixed $data Datos del componente (solo si success=true)
|
||||||
|
* @param string|null $error Mensaje de error (solo si success=false)
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private bool $success,
|
||||||
|
private mixed $data,
|
||||||
|
private ?string $error
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si la operación fue exitosa
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener datos del componente
|
||||||
|
*
|
||||||
|
* Solo válido si isSuccess() === true
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function getData(): mixed
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener mensaje de error
|
||||||
|
*
|
||||||
|
* Solo válido si isSuccess() === false
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getError(): ?string
|
||||||
|
{
|
||||||
|
return $this->error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Crear respuesta exitosa
|
||||||
|
*
|
||||||
|
* @param mixed $data Datos del componente
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function success(mixed $data): self
|
||||||
|
{
|
||||||
|
return new self(true, $data, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Crear respuesta de fallo
|
||||||
|
*
|
||||||
|
* @param string $error Mensaje de error
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function failure(string $error): self
|
||||||
|
{
|
||||||
|
return new self(false, null, $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a array para serialización
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => $this->success,
|
||||||
|
'data' => $this->data,
|
||||||
|
'error' => $this->error
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\GetComponent;
|
||||||
|
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
|
||||||
|
use ROITheme\Component\Domain\Exceptions\ComponentNotFoundException;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetComponentUseCase - Caso de Uso para obtener componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Orquestar la lógica de obtener un componente
|
||||||
|
*
|
||||||
|
* FLUJO:
|
||||||
|
* 1. Intentar obtener del cache
|
||||||
|
* 2. Si no está en cache, obtener del repositorio
|
||||||
|
* 3. Si no existe, lanzar excepción
|
||||||
|
* 4. Guardar en cache
|
||||||
|
* 5. Retornar respuesta
|
||||||
|
*
|
||||||
|
* OPTIMIZACIÓN:
|
||||||
|
* - Cache-first strategy para reducir queries a BD
|
||||||
|
* - TTL de 1 hora en cache
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $useCase = new GetComponentUseCase($repository, $cache);
|
||||||
|
* $request = new GetComponentRequest('top_bar');
|
||||||
|
* $response = $useCase->execute($request);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\GetComponent
|
||||||
|
*/
|
||||||
|
final class GetComponentUseCase
|
||||||
|
{
|
||||||
|
private const CACHE_TTL = 3600; // 1 hora
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ComponentRepositoryInterface $repository Repositorio de componentes
|
||||||
|
* @param CacheServiceInterface $cache Servicio de cache
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private ComponentRepositoryInterface $repository,
|
||||||
|
private CacheServiceInterface $cache
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecutar Use Case
|
||||||
|
*
|
||||||
|
* @param GetComponentRequest $request Datos de entrada
|
||||||
|
* @return GetComponentResponse Respuesta (éxito o fallo)
|
||||||
|
*/
|
||||||
|
public function execute(GetComponentRequest $request): GetComponentResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$componentNameString = $request->getComponentName();
|
||||||
|
$componentName = new ComponentName($componentNameString);
|
||||||
|
$cacheKey = $this->getCacheKey($componentNameString);
|
||||||
|
|
||||||
|
// 1. Intentar obtener del cache
|
||||||
|
$cached = $this->cache->get($cacheKey);
|
||||||
|
|
||||||
|
if ($cached !== null) {
|
||||||
|
return GetComponentResponse::success($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Si no está en cache, obtener del repositorio
|
||||||
|
$component = $this->repository->findByName($componentName);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
throw new ComponentNotFoundException(
|
||||||
|
"Component '{$componentNameString}' not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $component->toArray();
|
||||||
|
|
||||||
|
// 3. Guardar en cache
|
||||||
|
$this->cache->set($cacheKey, $data, self::CACHE_TTL);
|
||||||
|
|
||||||
|
// 4. Retornar respuesta exitosa
|
||||||
|
return GetComponentResponse::success($data);
|
||||||
|
|
||||||
|
} catch (ComponentNotFoundException $e) {
|
||||||
|
return GetComponentResponse::failure($e->getMessage());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return GetComponentResponse::failure(
|
||||||
|
'Unexpected error: ' . $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar key de cache para componente
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getCacheKey(string $componentName): string
|
||||||
|
{
|
||||||
|
return "component_{$componentName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\SaveComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaveComponentRequest - DTO de entrada para guardar componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular los datos de entrada para el Use Case de guardar un componente
|
||||||
|
*
|
||||||
|
* CARACTERÍSTICAS:
|
||||||
|
* - Inmutable
|
||||||
|
* - Sin lógica de negocio
|
||||||
|
* - Validación básica de tipos (PHP hará type checking)
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $request = new SaveComponentRequest('top_bar', [
|
||||||
|
* 'configuration' => ['content' => ['message_text' => 'Welcome']],
|
||||||
|
* 'visibility' => ['desktop' => true, 'mobile' => true],
|
||||||
|
* 'is_enabled' => true
|
||||||
|
* ]);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\SaveComponent
|
||||||
|
*/
|
||||||
|
final class SaveComponentRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $componentName Nombre del componente a guardar (e.g., 'top_bar', 'footer_cta')
|
||||||
|
* @param array $data Datos del componente (configuration, visibility, is_enabled, schema_version)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private string $componentName,
|
||||||
|
private array $data
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener nombre del componente
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getComponentName(): string
|
||||||
|
{
|
||||||
|
return $this->componentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener datos del componente
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getData(): array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Crear desde array
|
||||||
|
*
|
||||||
|
* Útil para crear desde datos POST/JSON
|
||||||
|
*
|
||||||
|
* @param array $data Array con keys 'component_name' y 'data'
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$data['component_name'] ?? '',
|
||||||
|
$data['data'] ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\SaveComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaveComponentResponse - DTO de salida para guardar componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular el resultado del Use Case (éxito o fallo)
|
||||||
|
*
|
||||||
|
* PATRÓN: Success/Failure
|
||||||
|
* - Éxito: success=true, data contiene el componente guardado
|
||||||
|
* - Fallo: success=false, errors contiene array de errores
|
||||||
|
*
|
||||||
|
* CARACTERÍSTICAS:
|
||||||
|
* - Inmutable
|
||||||
|
* - Constructor privado (usar factory methods)
|
||||||
|
* - Factory methods: success() y failure()
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* // Éxito
|
||||||
|
* $response = SaveComponentResponse::success(['name' => 'top_bar', ...]);
|
||||||
|
* if ($response->isSuccess()) {
|
||||||
|
* $data = $response->getData();
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Fallo
|
||||||
|
* $response = SaveComponentResponse::failure(['Error de validación']);
|
||||||
|
* if (!$response->isSuccess()) {
|
||||||
|
* $errors = $response->getErrors();
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\SaveComponent
|
||||||
|
*/
|
||||||
|
final class SaveComponentResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor privado - usar factory methods
|
||||||
|
*
|
||||||
|
* @param bool $success Indica si la operación fue exitosa
|
||||||
|
* @param mixed $data Datos del componente guardado (solo si success=true)
|
||||||
|
* @param array|null $errors Array de errores (solo si success=false)
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private bool $success,
|
||||||
|
private mixed $data,
|
||||||
|
private ?array $errors
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si la operación fue exitosa
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener datos del componente guardado
|
||||||
|
*
|
||||||
|
* Solo válido si isSuccess() === true
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function getData(): mixed
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener errores
|
||||||
|
*
|
||||||
|
* Solo válido si isSuccess() === false
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
public function getErrors(): ?array
|
||||||
|
{
|
||||||
|
return $this->errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Crear respuesta exitosa
|
||||||
|
*
|
||||||
|
* @param mixed $data Datos del componente guardado
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function success(mixed $data): self
|
||||||
|
{
|
||||||
|
return new self(true, $data, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Crear respuesta de fallo
|
||||||
|
*
|
||||||
|
* @param array $errors Array de mensajes de error
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function failure(array $errors): self
|
||||||
|
{
|
||||||
|
return new self(false, null, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a array para serialización (JSON, etc.)
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => $this->success,
|
||||||
|
'data' => $this->data,
|
||||||
|
'errors' => $this->errors
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\SaveComponent;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentVisibility;
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Shared\Domain\ValidationServiceInterface;
|
||||||
|
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
|
||||||
|
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaveComponentUseCase - Caso de Uso para guardar componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Orquestar la lógica de guardar un componente
|
||||||
|
*
|
||||||
|
* FLUJO:
|
||||||
|
* 1. Validar datos contra schema
|
||||||
|
* 2. Obtener datos sanitizados del resultado de validación
|
||||||
|
* 3. Crear entidad de dominio (Component)
|
||||||
|
* 4. Persistir en repositorio
|
||||||
|
* 5. Invalidar cache
|
||||||
|
* 6. Retornar respuesta
|
||||||
|
*
|
||||||
|
* PRINCIPIOS APLICADOS:
|
||||||
|
* - Single Responsibility: Solo orquesta, no tiene lógica de negocio
|
||||||
|
* - Dependency Inversion: Depende de interfaces, no implementaciones
|
||||||
|
* - Open/Closed: Extensible via cambio de implementaciones
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $useCase = new SaveComponentUseCase($repository, $validator, $cache);
|
||||||
|
* $request = new SaveComponentRequest('top_bar', $data);
|
||||||
|
* $response = $useCase->execute($request);
|
||||||
|
*
|
||||||
|
* if ($response->isSuccess()) {
|
||||||
|
* // Éxito
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\SaveComponent
|
||||||
|
*/
|
||||||
|
final class SaveComponentUseCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ComponentRepositoryInterface $repository Repositorio de componentes
|
||||||
|
* @param ValidationServiceInterface $validator Servicio de validación
|
||||||
|
* @param CacheServiceInterface $cache Servicio de cache
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private ComponentRepositoryInterface $repository,
|
||||||
|
private ValidationServiceInterface $validator,
|
||||||
|
private CacheServiceInterface $cache
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecutar Use Case
|
||||||
|
*
|
||||||
|
* @param SaveComponentRequest $request Datos de entrada
|
||||||
|
* @return SaveComponentResponse Respuesta (éxito o fallo)
|
||||||
|
*/
|
||||||
|
public function execute(SaveComponentRequest $request): SaveComponentResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 1. Validar datos contra schema
|
||||||
|
$validationResult = $this->validator->validate(
|
||||||
|
$request->getData(),
|
||||||
|
$request->getComponentName()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$validationResult->isValid()) {
|
||||||
|
return SaveComponentResponse::failure($validationResult->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener datos sanitizados del resultado de validación
|
||||||
|
$sanitized = $validationResult->getSanitizedData();
|
||||||
|
|
||||||
|
// 3. Crear entidad de dominio
|
||||||
|
$component = $this->createComponent($request->getComponentName(), $sanitized);
|
||||||
|
|
||||||
|
// 4. Persistir (save() retorna Component guardado)
|
||||||
|
$savedComponent = $this->repository->save($component);
|
||||||
|
|
||||||
|
// 5. Invalidar cache
|
||||||
|
$this->cache->delete("component_{$request->getComponentName()}");
|
||||||
|
|
||||||
|
// 6. Retornar respuesta exitosa
|
||||||
|
return SaveComponentResponse::success($savedComponent->toArray());
|
||||||
|
|
||||||
|
} catch (InvalidComponentException $e) {
|
||||||
|
// Errores de dominio (validación de reglas de negocio)
|
||||||
|
return SaveComponentResponse::failure([$e->getMessage()]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Errores inesperados
|
||||||
|
return SaveComponentResponse::failure([
|
||||||
|
'Unexpected error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear entidad Component desde datos validados
|
||||||
|
*
|
||||||
|
* @param string $name Nombre del componente
|
||||||
|
* @param array $data Datos validados y sanitizados
|
||||||
|
* @return Component
|
||||||
|
* @throws InvalidComponentException Si los datos no cumplen invariantes
|
||||||
|
*/
|
||||||
|
private function createComponent(string $name, array $data): Component
|
||||||
|
{
|
||||||
|
// Extraer o usar valores por defecto
|
||||||
|
$isEnabled = $data['is_enabled'] ?? true;
|
||||||
|
$schemaVersion = $data['schema_version'] ?? '1.0.0';
|
||||||
|
|
||||||
|
// Extraer grupos de configuración (visibility, content, styles, general)
|
||||||
|
$configData = [
|
||||||
|
'visibility' => $data['visibility'] ?? [],
|
||||||
|
'content' => $data['content'] ?? [],
|
||||||
|
'styles' => $data['styles'] ?? [],
|
||||||
|
'general' => $data['general'] ?? []
|
||||||
|
];
|
||||||
|
|
||||||
|
// Los datos de visibility se manejan aparte en ComponentVisibility
|
||||||
|
$visibilityData = $data['visibility'] ?? [];
|
||||||
|
|
||||||
|
// Crear Value Objects
|
||||||
|
$componentName = new ComponentName($name);
|
||||||
|
$configuration = ComponentConfiguration::fromArray($configData);
|
||||||
|
$visibility = ComponentVisibility::fromArray($visibilityData);
|
||||||
|
|
||||||
|
// Crear entidad Component
|
||||||
|
return new Component(
|
||||||
|
$componentName,
|
||||||
|
$configuration,
|
||||||
|
$visibility,
|
||||||
|
$isEnabled,
|
||||||
|
$schemaVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\SyncSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncSchemaRequest - DTO de entrada para sincronizar schemas
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular ruta del archivo JSON de schemas
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $request = new SyncSchemaRequest('/path/to/schemas.json');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\SyncSchema
|
||||||
|
*/
|
||||||
|
final class SyncSchemaRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $schemaFilePath Ruta al archivo JSON de schemas
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private string $schemaFilePath
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener ruta del archivo de schemas
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getSchemaFilePath(): string
|
||||||
|
{
|
||||||
|
return $this->schemaFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\SyncSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncSchemaResponse - DTO de salida para sincronizar schemas
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular resultado de sincronización
|
||||||
|
*
|
||||||
|
* DATOS INCLUIDOS:
|
||||||
|
* - success: bool
|
||||||
|
* - componentsAdded: array de nombres agregados
|
||||||
|
* - componentsUpdated: array de nombres actualizados
|
||||||
|
* - componentsDeleted: array de nombres eliminados
|
||||||
|
* - errors: array de errores
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\SyncSchema
|
||||||
|
*/
|
||||||
|
final class SyncSchemaResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param bool $success Indica si la sincronización fue exitosa
|
||||||
|
* @param array $componentsAdded Componentes agregados
|
||||||
|
* @param array $componentsUpdated Componentes actualizados
|
||||||
|
* @param array $componentsDeleted Componentes eliminados
|
||||||
|
* @param array $errors Errores ocurridos
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private bool $success,
|
||||||
|
private array $componentsAdded,
|
||||||
|
private array $componentsUpdated,
|
||||||
|
private array $componentsDeleted,
|
||||||
|
private array $errors
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentsAdded(): array
|
||||||
|
{
|
||||||
|
return $this->componentsAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentsUpdated(): array
|
||||||
|
{
|
||||||
|
return $this->componentsUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentsDeleted(): array
|
||||||
|
{
|
||||||
|
return $this->componentsDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getErrors(): array
|
||||||
|
{
|
||||||
|
return $this->errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Sincronización exitosa
|
||||||
|
*/
|
||||||
|
public static function success(
|
||||||
|
array $added,
|
||||||
|
array $updated,
|
||||||
|
array $deleted
|
||||||
|
): self {
|
||||||
|
return new self(true, $added, $updated, $deleted, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method: Sincronización con errores
|
||||||
|
*/
|
||||||
|
public static function failure(array $errors): self
|
||||||
|
{
|
||||||
|
return new self(false, [], [], [], $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a array
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => $this->success,
|
||||||
|
'components_added' => $this->componentsAdded,
|
||||||
|
'components_updated' => $this->componentsUpdated,
|
||||||
|
'components_deleted' => $this->componentsDeleted,
|
||||||
|
'errors' => $this->errors
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener resumen de cambios
|
||||||
|
*/
|
||||||
|
public function getSummary(): string
|
||||||
|
{
|
||||||
|
$added = count($this->componentsAdded);
|
||||||
|
$updated = count($this->componentsUpdated);
|
||||||
|
$deleted = count($this->componentsDeleted);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Added: %d, Updated: %d, Deleted: %d',
|
||||||
|
$added,
|
||||||
|
$updated,
|
||||||
|
$deleted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Application\UseCases\SyncSchema;
|
||||||
|
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncSchemaUseCase - Sincronizar schemas JSON → BD
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Orquestar sincronización de componentes desde archivo JSON
|
||||||
|
*
|
||||||
|
* FLUJO:
|
||||||
|
* 1. Leer archivo JSON
|
||||||
|
* 2. Validar estructura
|
||||||
|
* 3. Comparar con BD (agregar/actualizar/eliminar)
|
||||||
|
* 4. Persistir cambios
|
||||||
|
* 5. Retornar resumen
|
||||||
|
*
|
||||||
|
* @package ROITheme\Application\UseCases\SyncSchema
|
||||||
|
*/
|
||||||
|
final class SyncSchemaUseCase
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ComponentRepositoryInterface $componentRepository,
|
||||||
|
private ComponentDefaultsRepositoryInterface $defaultsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecutar sincronización
|
||||||
|
*/
|
||||||
|
public function execute(SyncSchemaRequest $request): SyncSchemaResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 1. Leer y parsear JSON
|
||||||
|
$schemas = $this->readSchemas($request->getSchemaFilePath());
|
||||||
|
|
||||||
|
if (empty($schemas)) {
|
||||||
|
return SyncSchemaResponse::failure(['No schemas found in file']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener componentes actuales de BD
|
||||||
|
$currentComponents = $this->componentRepository->findAll();
|
||||||
|
$currentNames = array_map(
|
||||||
|
fn($c) => $c->name()->value(),
|
||||||
|
$currentComponents
|
||||||
|
);
|
||||||
|
$schemaNames = array_keys($schemas);
|
||||||
|
|
||||||
|
// 3. Determinar cambios
|
||||||
|
$toAdd = array_diff($schemaNames, $currentNames);
|
||||||
|
$toUpdate = array_intersect($schemaNames, $currentNames);
|
||||||
|
$toDelete = array_diff($currentNames, $schemaNames);
|
||||||
|
|
||||||
|
// 4. Aplicar cambios
|
||||||
|
$added = $this->addComponents($toAdd, $schemas);
|
||||||
|
$updated = $this->updateComponents($toUpdate, $schemas);
|
||||||
|
$deleted = $this->deleteComponents($toDelete);
|
||||||
|
|
||||||
|
// 5. Retornar resumen
|
||||||
|
return SyncSchemaResponse::success($added, $updated, $deleted);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return SyncSchemaResponse::failure([$e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leer schemas desde archivo JSON
|
||||||
|
*/
|
||||||
|
private function readSchemas(string $filePath): array
|
||||||
|
{
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw new \RuntimeException("Schema file not found: {$filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($filePath);
|
||||||
|
$schemas = json_decode($content, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new \RuntimeException('Invalid JSON: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schemas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agregar nuevos componentes
|
||||||
|
*/
|
||||||
|
private function addComponents(array $names, array $schemas): array
|
||||||
|
{
|
||||||
|
$added = [];
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$schema = $schemas[$name];
|
||||||
|
$componentName = new ComponentName($name);
|
||||||
|
$configuration = ComponentConfiguration::fromArray($schema);
|
||||||
|
$this->defaultsRepository->save($componentName, $configuration);
|
||||||
|
$added[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar componentes existentes
|
||||||
|
*/
|
||||||
|
private function updateComponents(array $names, array $schemas): array
|
||||||
|
{
|
||||||
|
$updated = [];
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$schema = $schemas[$name];
|
||||||
|
$componentName = new ComponentName($name);
|
||||||
|
$configuration = ComponentConfiguration::fromArray($schema);
|
||||||
|
$this->defaultsRepository->save($componentName, $configuration);
|
||||||
|
$updated[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar componentes obsoletos
|
||||||
|
*/
|
||||||
|
private function deleteComponents(array $names): array
|
||||||
|
{
|
||||||
|
$deleted = [];
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$this->defaultsRepository->deleteDefaults($name);
|
||||||
|
$deleted[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/Component/Domain/Component.php
Normal file
324
src/Component/Domain/Component.php
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentVisibility;
|
||||||
|
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component - Entidad del Dominio
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar un componente del tema con toda su lógica de negocio
|
||||||
|
*
|
||||||
|
* INVARIANTES (Reglas que SIEMPRE deben cumplirse):
|
||||||
|
* 1. Un componente siempre tiene un nombre válido
|
||||||
|
* 2. Un componente siempre tiene configuración (puede estar vacía)
|
||||||
|
* 3. Si tiene CTA URL debe tener CTA text (validado en ComponentConfiguration)
|
||||||
|
* 4. Los colores siempre están en formato hexadecimal válido
|
||||||
|
* 5. El timestamp de updated_at es siempre >= created_at
|
||||||
|
*
|
||||||
|
* ENTIDAD vs VALUE OBJECT:
|
||||||
|
* - Component es ENTIDAD porque tiene identidad (puede cambiar configuración pero sigue siendo el mismo componente)
|
||||||
|
* - ComponentName es VALUE OBJECT (dos nombres iguales son indistinguibles)
|
||||||
|
* - ComponentConfiguration es VALUE OBJECT (inmutable)
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $component = new Component(
|
||||||
|
* ComponentName::fromString('top_bar'),
|
||||||
|
* ComponentConfiguration::fromFlat([
|
||||||
|
* 'enabled' => true,
|
||||||
|
* 'message_text' => 'Welcome!'
|
||||||
|
* ]),
|
||||||
|
* ComponentVisibility::allDevices()
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // Actualizar configuración (inmutabilidad)
|
||||||
|
* $updated = $component->updateConfiguration(
|
||||||
|
* $component->configuration()->withValue('content', 'message_text', 'Hello!')
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component
|
||||||
|
*/
|
||||||
|
final class Component
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var ComponentName Nombre del componente (inmutable)
|
||||||
|
*/
|
||||||
|
private ComponentName $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ComponentConfiguration Configuración del componente
|
||||||
|
*/
|
||||||
|
private ComponentConfiguration $configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ComponentVisibility Visibilidad del componente
|
||||||
|
*/
|
||||||
|
private ComponentVisibility $visibility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Componente habilitado
|
||||||
|
*/
|
||||||
|
private bool $isEnabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Versión del schema
|
||||||
|
*/
|
||||||
|
private string $schemaVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \DateTimeImmutable Fecha de creación
|
||||||
|
*/
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \DateTimeImmutable Fecha de última actualización
|
||||||
|
*/
|
||||||
|
private \DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @param ComponentConfiguration $configuration
|
||||||
|
* @param ComponentVisibility $visibility
|
||||||
|
* @param bool $isEnabled
|
||||||
|
* @param string $schemaVersion
|
||||||
|
* @param \DateTimeImmutable|null $createdAt
|
||||||
|
* @param \DateTimeImmutable|null $updatedAt
|
||||||
|
* @throws InvalidComponentException Si se violan invariantes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
ComponentName $name,
|
||||||
|
ComponentConfiguration $configuration,
|
||||||
|
ComponentVisibility $visibility,
|
||||||
|
bool $isEnabled = true,
|
||||||
|
string $schemaVersion = '1.0.0',
|
||||||
|
?\DateTimeImmutable $createdAt = null,
|
||||||
|
?\DateTimeImmutable $updatedAt = null
|
||||||
|
) {
|
||||||
|
$this->name = $name;
|
||||||
|
$this->configuration = $configuration;
|
||||||
|
$this->visibility = $visibility;
|
||||||
|
$this->isEnabled = $isEnabled;
|
||||||
|
$this->schemaVersion = $schemaVersion;
|
||||||
|
$this->createdAt = $createdAt ?? new \DateTimeImmutable();
|
||||||
|
$this->updatedAt = $updatedAt ?? new \DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->validateInvariants();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener nombre del componente
|
||||||
|
*
|
||||||
|
* @return ComponentName
|
||||||
|
*/
|
||||||
|
public function name(): ComponentName
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener configuración del componente
|
||||||
|
*
|
||||||
|
* @return ComponentConfiguration
|
||||||
|
*/
|
||||||
|
public function configuration(): ComponentConfiguration
|
||||||
|
{
|
||||||
|
return $this->configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener visibilidad del componente
|
||||||
|
*
|
||||||
|
* @return ComponentVisibility
|
||||||
|
*/
|
||||||
|
public function visibility(): ComponentVisibility
|
||||||
|
{
|
||||||
|
return $this->visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el componente está habilitado
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener versión del schema
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function schemaVersion(): string
|
||||||
|
{
|
||||||
|
return $this->schemaVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener fecha de creación
|
||||||
|
*
|
||||||
|
* @return \DateTimeImmutable
|
||||||
|
*/
|
||||||
|
public function createdAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener fecha de última actualización
|
||||||
|
*
|
||||||
|
* @return \DateTimeImmutable
|
||||||
|
*/
|
||||||
|
public function updatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear nuevo componente con configuración actualizada
|
||||||
|
* (Inmutabilidad: retorna nueva instancia)
|
||||||
|
*
|
||||||
|
* @param ComponentConfiguration $configuration Nueva configuración
|
||||||
|
* @return self Nueva instancia
|
||||||
|
*/
|
||||||
|
public function updateConfiguration(ComponentConfiguration $configuration): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->name,
|
||||||
|
$configuration,
|
||||||
|
$this->visibility,
|
||||||
|
$this->isEnabled,
|
||||||
|
$this->schemaVersion,
|
||||||
|
$this->createdAt,
|
||||||
|
new \DateTimeImmutable()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear nuevo componente con visibilidad actualizada
|
||||||
|
* (Inmutabilidad: retorna nueva instancia)
|
||||||
|
*
|
||||||
|
* @param ComponentVisibility $visibility Nueva visibilidad
|
||||||
|
* @return self Nueva instancia
|
||||||
|
*/
|
||||||
|
public function updateVisibility(ComponentVisibility $visibility): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->name,
|
||||||
|
$this->configuration,
|
||||||
|
$visibility,
|
||||||
|
$this->isEnabled,
|
||||||
|
$this->schemaVersion,
|
||||||
|
$this->createdAt,
|
||||||
|
new \DateTimeImmutable()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear nuevo componente habilitado
|
||||||
|
* (Inmutabilidad: retorna nueva instancia)
|
||||||
|
*
|
||||||
|
* @return self Nueva instancia con isEnabled=true
|
||||||
|
*/
|
||||||
|
public function enable(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->name,
|
||||||
|
$this->configuration,
|
||||||
|
$this->visibility,
|
||||||
|
true,
|
||||||
|
$this->schemaVersion,
|
||||||
|
$this->createdAt,
|
||||||
|
new \DateTimeImmutable()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear nuevo componente deshabilitado
|
||||||
|
* (Inmutabilidad: retorna nueva instancia)
|
||||||
|
*
|
||||||
|
* @return self Nueva instancia con isEnabled=false
|
||||||
|
*/
|
||||||
|
public function disable(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->name,
|
||||||
|
$this->configuration,
|
||||||
|
$this->visibility,
|
||||||
|
false,
|
||||||
|
$this->schemaVersion,
|
||||||
|
$this->createdAt,
|
||||||
|
new \DateTimeImmutable()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar con otro componente
|
||||||
|
* (Dos componentes son iguales si tienen el mismo nombre)
|
||||||
|
*
|
||||||
|
* @param Component $other Otro componente
|
||||||
|
* @return bool True si son el mismo componente
|
||||||
|
*/
|
||||||
|
public function equals(Component $other): bool
|
||||||
|
{
|
||||||
|
return $this->name->equals($other->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a array para serialización
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->name->value(),
|
||||||
|
'configuration' => $this->configuration->all(),
|
||||||
|
'visibility' => $this->visibility->toArray(),
|
||||||
|
'is_enabled' => $this->isEnabled,
|
||||||
|
'schema_version' => $this->schemaVersion,
|
||||||
|
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => $this->updatedAt->format('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a string para debugging
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'Component(%s, enabled=%s, schema=%s)',
|
||||||
|
$this->name->value(),
|
||||||
|
$this->isEnabled ? 'true' : 'false',
|
||||||
|
$this->schemaVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar invariantes del componente
|
||||||
|
*
|
||||||
|
* @throws InvalidComponentException Si se viola algún invariante
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function validateInvariants(): void
|
||||||
|
{
|
||||||
|
// Invariante: updated_at >= created_at
|
||||||
|
if ($this->updatedAt < $this->createdAt) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
'Updated timestamp cannot be before created timestamp'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Component/Domain/Component.php.backup
Normal file
95
src/Component/Domain/Component.php.backup
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Domain\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component - Entidad del Dominio
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar un componente del tema con toda su configuración
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $component = new Component('hero-1', 'hero-section', [
|
||||||
|
* 'visibility' => ['is_enabled' => true],
|
||||||
|
* 'title' => ['text' => 'Welcome']
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* echo $component->getId(); // 'hero-1'
|
||||||
|
* echo $component->getType(); // 'hero-section'
|
||||||
|
* $data = $component->getData(); // array con configuración
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component
|
||||||
|
*/
|
||||||
|
final class Component
|
||||||
|
{
|
||||||
|
private string $id;
|
||||||
|
private string $type;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $id ID único del componente
|
||||||
|
* @param string $type Tipo de componente (ej: 'navbar', 'footer', 'hero-section')
|
||||||
|
* @param array $data Datos de configuración del componente
|
||||||
|
*/
|
||||||
|
public function __construct(string $id, string $type, array $data = [])
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
$this->type = $type;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener ID del componente
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getId(): string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener tipo de componente
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener datos de configuración
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getData(): array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el componente está habilitado
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->data['visibility']['is_enabled'] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar datos del componente
|
||||||
|
*
|
||||||
|
* @param array $data Nuevos datos
|
||||||
|
* @return self Nueva instancia con datos actualizados
|
||||||
|
*/
|
||||||
|
public function withData(array $data): self
|
||||||
|
{
|
||||||
|
return new self($this->id, $this->type, $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Component/Domain/ComponentRepositoryInterface.php
Normal file
47
src/Component/Domain/ComponentRepositoryInterface.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component Repository Interface
|
||||||
|
*
|
||||||
|
* Defines the contract for component persistence.
|
||||||
|
* This interface belongs to the Domain layer, but implementations will be in Infrastructure.
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component
|
||||||
|
*/
|
||||||
|
interface ComponentRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Save a component
|
||||||
|
*
|
||||||
|
* @param Component $component Component to save
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function save(Component $component): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find component by ID
|
||||||
|
*
|
||||||
|
* @param int $id Component ID
|
||||||
|
* @return Component|null
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all components
|
||||||
|
*
|
||||||
|
* @return array<Component>
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a component
|
||||||
|
*
|
||||||
|
* @param int $id Component ID
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function delete(int $id): bool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentNotFoundException - Excepción de Dominio para componente no encontrado
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar errores cuando un componente solicitado no existe
|
||||||
|
*
|
||||||
|
* CUÁNDO LANZAR:
|
||||||
|
* - Búsqueda de componente por nombre que no existe en el repositorio
|
||||||
|
* - Intento de actualizar/eliminar componente inexistente
|
||||||
|
* - Referencia a componente que fue eliminado
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $component = $repository->findByName($name);
|
||||||
|
*
|
||||||
|
* if ($component === null) {
|
||||||
|
* throw ComponentNotFoundException::withName($name);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component\Exceptions
|
||||||
|
*/
|
||||||
|
class ComponentNotFoundException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $message Mensaje de error
|
||||||
|
* @param int $code Código de error (opcional)
|
||||||
|
* @param \Throwable|null $previous Excepción anterior (opcional)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear excepción para componente no encontrado por nombre
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function withName(string $componentName): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Component "%s" not found', $componentName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear excepción para componente no encontrado por ID
|
||||||
|
*
|
||||||
|
* @param int $componentId
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function withId(int $componentId): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Component with ID %d not found', $componentId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InvalidComponentException - Excepción de Dominio para componentes inválidos
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar errores de validación de reglas de negocio de componentes
|
||||||
|
*
|
||||||
|
* CUÁNDO LANZAR:
|
||||||
|
* - Nombre de componente inválido (formato, longitud)
|
||||||
|
* - Configuración inválida (CTA URL sin CTA text, colores mal formateados)
|
||||||
|
* - Visibility inválida (combinaciones no permitidas)
|
||||||
|
* - Content inválido (URLs mal formateadas, textos vacíos cuando requeridos)
|
||||||
|
* - Cualquier violación de invariantes del dominio
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* if (empty($name)) {
|
||||||
|
* throw new InvalidComponentException('Component name cannot be empty');
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component\Exceptions
|
||||||
|
*/
|
||||||
|
class InvalidComponentException extends \DomainException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $message Mensaje de error
|
||||||
|
* @param int $code Código de error (opcional)
|
||||||
|
* @param \Throwable|null $previous Excepción anterior (opcional)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear excepción para nombre inválido
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @param string $reason
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function invalidName(string $name, string $reason): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Invalid component name "%s": %s', $name, $reason)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear excepción para configuración inválida
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @param string $reason
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function invalidConfiguration(string $componentName, string $reason): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Invalid configuration for component "%s": %s', $componentName, $reason)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear excepción para CTA inválido
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function ctaUrlRequiresText(string $componentName): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Component "%s" has CTA URL but missing CTA text', $componentName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear excepción para color inválido
|
||||||
|
*
|
||||||
|
* @param string $colorKey
|
||||||
|
* @param string $value
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function invalidColor(string $colorKey, string $value): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Invalid color format for "%s": "%s". Expected hexadecimal (e.g., #000000)', $colorKey, $value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Component/Domain/FormBuilderInterface.php
Normal file
59
src/Component/Domain/FormBuilderInterface.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormBuilderInterface - Contrato para constructores de formularios de componentes
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Definir el contrato que deben cumplir todos los constructores
|
||||||
|
* de formularios de administración para componentes.
|
||||||
|
*
|
||||||
|
* PRINCIPIOS:
|
||||||
|
* - Interface Segregation: Una sola responsabilidad - construir formularios
|
||||||
|
* - Dependency Inversion: Depender de abstracción, no de implementación
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* final class MyFormBuilder implements FormBuilderInterface
|
||||||
|
* {
|
||||||
|
* private string $componentId;
|
||||||
|
* private array $data;
|
||||||
|
*
|
||||||
|
* public function __construct(string $componentId, array $data = [])
|
||||||
|
* {
|
||||||
|
* $this->componentId = $componentId;
|
||||||
|
* $this->data = $data;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* public function build(): string
|
||||||
|
* {
|
||||||
|
* // Generar HTML del formulario
|
||||||
|
* return $html;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* public function getComponentId(): string
|
||||||
|
* {
|
||||||
|
* return $this->componentId;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component
|
||||||
|
*/
|
||||||
|
interface FormBuilderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Construir el HTML del formulario de configuración
|
||||||
|
*
|
||||||
|
* @return string HTML del formulario generado
|
||||||
|
*/
|
||||||
|
public function build(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener el ID del componente asociado
|
||||||
|
*
|
||||||
|
* @return string ID del componente
|
||||||
|
*/
|
||||||
|
public function getComponentId(): string;
|
||||||
|
}
|
||||||
53
src/Component/Domain/RendererInterface.php
Normal file
53
src/Component/Domain/RendererInterface.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RendererInterface - Contrato para renderizadores de componentes
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Definir el contrato que deben cumplir todos los renderizadores
|
||||||
|
* de componentes para generar HTML a partir de los datos del componente.
|
||||||
|
*
|
||||||
|
* PRINCIPIOS:
|
||||||
|
* - Interface Segregation: Una sola responsabilidad - renderizar HTML
|
||||||
|
* - Dependency Inversion: Depender de abstracción, no de implementación
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* final class MyRenderer implements RendererInterface
|
||||||
|
* {
|
||||||
|
* public function render(Component $component): string
|
||||||
|
* {
|
||||||
|
* $data = $component->getData();
|
||||||
|
* // Generar HTML
|
||||||
|
* return $html;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* public function supports(string $componentType): bool
|
||||||
|
* {
|
||||||
|
* return $componentType === 'my-component';
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component
|
||||||
|
*/
|
||||||
|
interface RendererInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Renderizar un componente a HTML
|
||||||
|
*
|
||||||
|
* @param Component $component Componente a renderizar
|
||||||
|
* @return string HTML generado
|
||||||
|
*/
|
||||||
|
public function render(Component $component): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si este renderizador soporta un tipo de componente
|
||||||
|
*
|
||||||
|
* @param string $componentType Tipo de componente (ej: 'navbar', 'footer')
|
||||||
|
* @return bool True si soporta el tipo, false en caso contrario
|
||||||
|
*/
|
||||||
|
public function supports(string $componentType): bool;
|
||||||
|
}
|
||||||
349
src/Component/Domain/ValueObjects/ComponentConfiguration.php
Normal file
349
src/Component/Domain/ValueObjects/ComponentConfiguration.php
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain\ValueObjects;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentConfiguration - Value Object inmutable para configuración de componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar la configuración completa de un componente agrupada por categorías
|
||||||
|
*
|
||||||
|
* REGLAS DE NEGOCIO:
|
||||||
|
* - La configuración se organiza en grupos: visibility, content, styles, general
|
||||||
|
* - Cada grupo contiene pares clave-valor
|
||||||
|
* - Los valores pueden ser: string, boolean, integer, float, array
|
||||||
|
* - Si existe cta_url debe existir cta_text (y viceversa)
|
||||||
|
* - Los colores deben estar en formato hexadecimal válido
|
||||||
|
*
|
||||||
|
* INVARIANTES:
|
||||||
|
* - Una vez creada, la configuración no puede cambiar (inmutable)
|
||||||
|
* - La estructura de grupos siempre está presente (aunque vacía)
|
||||||
|
* - Los tipos de datos se preservan correctamente
|
||||||
|
*
|
||||||
|
* ESTRUCTURA:
|
||||||
|
* ```php
|
||||||
|
* [
|
||||||
|
* 'visibility' => [
|
||||||
|
* 'enabled' => true,
|
||||||
|
* 'visible_desktop' => true,
|
||||||
|
* 'visible_mobile' => false
|
||||||
|
* ],
|
||||||
|
* 'content' => [
|
||||||
|
* 'message_text' => 'Welcome!',
|
||||||
|
* 'cta_text' => 'Click here',
|
||||||
|
* 'cta_url' => 'https://example.com'
|
||||||
|
* ],
|
||||||
|
* 'styles' => [
|
||||||
|
* 'background_color' => '#000000',
|
||||||
|
* 'text_color' => '#ffffff',
|
||||||
|
* 'height' => 50
|
||||||
|
* ],
|
||||||
|
* 'general' => [
|
||||||
|
* 'priority' => 10,
|
||||||
|
* 'version' => '1.0.0'
|
||||||
|
* ]
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component\ValueObjects
|
||||||
|
*/
|
||||||
|
final class ComponentConfiguration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Grupos de configuración válidos
|
||||||
|
*/
|
||||||
|
private const VALID_GROUPS = ['visibility', 'content', 'styles', 'general'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $configuration Configuración agrupada
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
*/
|
||||||
|
public function __construct(private array $configuration)
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener toda la configuración
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener configuración de un grupo específico
|
||||||
|
*
|
||||||
|
* @param string $group Nombre del grupo (visibility, content, styles, general)
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getGroup(string $group): array
|
||||||
|
{
|
||||||
|
return $this->configuration[$group] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener valor de una clave específica dentro de un grupo
|
||||||
|
*
|
||||||
|
* @param string $group Nombre del grupo
|
||||||
|
* @param string $key Clave de configuración
|
||||||
|
* @param mixed $default Valor por defecto si no existe
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function get(string $group, string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->configuration[$group][$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si existe una clave en un grupo
|
||||||
|
*
|
||||||
|
* @param string $group
|
||||||
|
* @param string $key
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has(string $group, string $key): bool
|
||||||
|
{
|
||||||
|
return isset($this->configuration[$group][$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el componente tiene CTA URL
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasCTAURL(): bool
|
||||||
|
{
|
||||||
|
return $this->has('content', 'cta_url') && !empty($this->get('content', 'cta_url'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el componente tiene CTA text
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasCTAText(): bool
|
||||||
|
{
|
||||||
|
return $this->has('content', 'cta_text') && !empty($this->get('content', 'cta_text'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si el componente está habilitado
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->get('visibility', 'enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear nueva configuración con un valor actualizado
|
||||||
|
* (Inmutabilidad: retorna nueva instancia)
|
||||||
|
*
|
||||||
|
* @param string $group
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $value
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function withValue(string $group, string $key, mixed $value): self
|
||||||
|
{
|
||||||
|
$newConfiguration = $this->configuration;
|
||||||
|
|
||||||
|
if (!isset($newConfiguration[$group])) {
|
||||||
|
$newConfiguration[$group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newConfiguration[$group][$key] = $value;
|
||||||
|
|
||||||
|
return new self($newConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear nueva configuración con un grupo actualizado
|
||||||
|
* (Inmutabilidad: retorna nueva instancia)
|
||||||
|
*
|
||||||
|
* @param string $group
|
||||||
|
* @param array $values
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function withGroup(string $group, array $values): self
|
||||||
|
{
|
||||||
|
$newConfiguration = $this->configuration;
|
||||||
|
$newConfiguration[$group] = $values;
|
||||||
|
|
||||||
|
return new self($newConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar reglas de negocio de la configuración
|
||||||
|
*
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
// Regla 1: Debe ser un array
|
||||||
|
if (!is_array($this->configuration)) {
|
||||||
|
throw new InvalidComponentException('Configuration must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 2: Los grupos deben ser válidos
|
||||||
|
foreach (array_keys($this->configuration) as $group) {
|
||||||
|
if (!in_array($group, self::VALID_GROUPS, true)) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
sprintf(
|
||||||
|
'Invalid configuration group "%s". Valid groups are: %s',
|
||||||
|
$group,
|
||||||
|
implode(', ', self::VALID_GROUPS)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 3: Si existe cta_url debe existir cta_text (y viceversa)
|
||||||
|
if ($this->hasCTAURL() && !$this->hasCTAText()) {
|
||||||
|
throw new InvalidComponentException('CTA URL requires CTA text');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasCTAText() && !$this->hasCTAURL()) {
|
||||||
|
throw new InvalidComponentException('CTA text requires CTA URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 4: Los colores deben ser hexadecimales válidos
|
||||||
|
$this->validateColors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar que los colores estén en formato hexadecimal
|
||||||
|
*
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function validateColors(): void
|
||||||
|
{
|
||||||
|
$styles = $this->getGroup('styles');
|
||||||
|
|
||||||
|
foreach ($styles as $key => $value) {
|
||||||
|
if (str_ends_with($key, '_color') && !empty($value)) {
|
||||||
|
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
sprintf(
|
||||||
|
'Color "%s" must be in hexadecimal format (e.g., #000000). Got: %s',
|
||||||
|
$key,
|
||||||
|
$value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear desde array flat (legacy)
|
||||||
|
*
|
||||||
|
* Convierte de:
|
||||||
|
* ```php
|
||||||
|
* [
|
||||||
|
* 'enabled' => true,
|
||||||
|
* 'message_text' => 'Hello',
|
||||||
|
* 'background_color' => '#000'
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* A estructura agrupada.
|
||||||
|
*
|
||||||
|
* @param array $flatConfig
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromFlat(array $flatConfig): self
|
||||||
|
{
|
||||||
|
$grouped = [
|
||||||
|
'visibility' => [],
|
||||||
|
'content' => [],
|
||||||
|
'styles' => [],
|
||||||
|
'general' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($flatConfig as $key => $value) {
|
||||||
|
$group = self::inferGroup($key);
|
||||||
|
$grouped[$group][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($grouped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear desde array agrupado
|
||||||
|
*
|
||||||
|
* Espera estructura ya agrupada:
|
||||||
|
* ```php
|
||||||
|
* [
|
||||||
|
* 'visibility' => ['enabled' => true, ...],
|
||||||
|
* 'content' => ['message_text' => 'Hello', ...],
|
||||||
|
* 'styles' => ['background_color' => '#000', ...],
|
||||||
|
* 'general' => ['priority' => 10, ...]
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param array $groupedConfig Configuración ya agrupada
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $groupedConfig): self
|
||||||
|
{
|
||||||
|
// Si ya está agrupado, usarlo directamente
|
||||||
|
return new self($groupedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inferir grupo desde clave (heurística)
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function inferGroup(string $key): string
|
||||||
|
{
|
||||||
|
// Visibility
|
||||||
|
if (in_array($key, ['enabled', 'visible_desktop', 'visible_mobile', 'visible_tablet'], true)) {
|
||||||
|
return 'visibility';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (str_starts_with($key, 'message_') ||
|
||||||
|
str_starts_with($key, 'cta_') ||
|
||||||
|
str_starts_with($key, 'title_')) {
|
||||||
|
return 'content';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
if (str_ends_with($key, '_color') ||
|
||||||
|
str_ends_with($key, '_height') ||
|
||||||
|
str_ends_with($key, '_width') ||
|
||||||
|
str_ends_with($key, '_size') ||
|
||||||
|
str_ends_with($key, '_font')) {
|
||||||
|
return 'styles';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear configuración vacía
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function empty(): self
|
||||||
|
{
|
||||||
|
return new self([
|
||||||
|
'visibility' => [],
|
||||||
|
'content' => [],
|
||||||
|
'styles' => [],
|
||||||
|
'general' => []
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
src/Component/Domain/ValueObjects/ComponentContent.php
Normal file
272
src/Component/Domain/ValueObjects/ComponentContent.php
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain\ValueObjects;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentContent - Value Object inmutable para contenido de componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar y validar el contenido textual y de CTA de un componente
|
||||||
|
*
|
||||||
|
* REGLAS DE NEGOCIO:
|
||||||
|
* - El mensaje de texto puede estar vacío (componentes sin texto)
|
||||||
|
* - Si existe CTA URL debe existir CTA text (y viceversa)
|
||||||
|
* - CTA URL debe ser una URL válida (formato http/https)
|
||||||
|
* - CTA text no puede estar vacío si existe CTA URL
|
||||||
|
* - Title es opcional
|
||||||
|
*
|
||||||
|
* CASOS DE USO:
|
||||||
|
* - Componente solo con mensaje de texto
|
||||||
|
* - Componente con mensaje y CTA
|
||||||
|
* - Componente con title, mensaje y CTA
|
||||||
|
* - Componente sin contenido textual (solo estilos/visibilidad)
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* // Componente con mensaje y CTA
|
||||||
|
* $content = new ComponentContent(
|
||||||
|
* messageText: 'Welcome to our site!',
|
||||||
|
* ctaText: 'Learn More',
|
||||||
|
* ctaUrl: 'https://example.com/about'
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // Componente solo con mensaje
|
||||||
|
* $content = ComponentContent::messageOnly('Hello World!');
|
||||||
|
*
|
||||||
|
* // Componente vacío
|
||||||
|
* $content = ComponentContent::empty();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component\ValueObjects
|
||||||
|
*/
|
||||||
|
final class ComponentContent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $messageText Texto del mensaje principal
|
||||||
|
* @param string|null $ctaText Texto del Call-to-Action
|
||||||
|
* @param string|null $ctaUrl URL del Call-to-Action
|
||||||
|
* @param string|null $title Título del componente
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private string $messageText = '',
|
||||||
|
private ?string $ctaText = null,
|
||||||
|
private ?string $ctaUrl = null,
|
||||||
|
private ?string $title = null
|
||||||
|
) {
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener mensaje de texto
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function messageText(): string
|
||||||
|
{
|
||||||
|
return $this->messageText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener texto del CTA
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function ctaText(): ?string
|
||||||
|
{
|
||||||
|
return $this->ctaText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener URL del CTA
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function ctaUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->ctaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener título
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function title(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si tiene mensaje de texto
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasMessage(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->messageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si tiene CTA
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasCTA(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->ctaText) && !empty($this->ctaUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si tiene título
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasTitle(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si está completamente vacío
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return empty($this->messageText) &&
|
||||||
|
empty($this->ctaText) &&
|
||||||
|
empty($this->ctaUrl) &&
|
||||||
|
empty($this->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a array
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'message_text' => $this->messageText,
|
||||||
|
'cta_text' => $this->ctaText,
|
||||||
|
'cta_url' => $this->ctaUrl,
|
||||||
|
'title' => $this->title
|
||||||
|
], fn($value) => $value !== null && $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar reglas de negocio
|
||||||
|
*
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
// Regla 1: Si existe CTA URL debe existir CTA text
|
||||||
|
if (!empty($this->ctaUrl) && empty($this->ctaText)) {
|
||||||
|
throw new InvalidComponentException('CTA URL requires CTA text');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 2: Si existe CTA text debe existir CTA URL
|
||||||
|
if (!empty($this->ctaText) && empty($this->ctaUrl)) {
|
||||||
|
throw new InvalidComponentException('CTA text requires CTA URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 3: CTA URL debe ser URL válida
|
||||||
|
if (!empty($this->ctaUrl) && !$this->isValidUrl($this->ctaUrl)) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
sprintf('Invalid CTA URL format: %s', $this->ctaUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar si una string es URL válida
|
||||||
|
*
|
||||||
|
* @param string $url
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function isValidUrl(string $url): bool
|
||||||
|
{
|
||||||
|
// Debe comenzar con http:// o https://
|
||||||
|
if (!preg_match('/^https?:\/\//i', $url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar filter_var para validación completa
|
||||||
|
return filter_var($url, FILTER_VALIDATE_URL) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Contenido vacío
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function empty(): self
|
||||||
|
{
|
||||||
|
return new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Solo mensaje
|
||||||
|
*
|
||||||
|
* @param string $message
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function messageOnly(string $message): self
|
||||||
|
{
|
||||||
|
return new self(messageText: $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Mensaje con CTA
|
||||||
|
*
|
||||||
|
* @param string $message
|
||||||
|
* @param string $ctaText
|
||||||
|
* @param string $ctaUrl
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function withCTA(string $message, string $ctaText, string $ctaUrl): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
messageText: $message,
|
||||||
|
ctaText: $ctaText,
|
||||||
|
ctaUrl: $ctaUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear desde array
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
messageText: $data['message_text'] ?? '',
|
||||||
|
ctaText: $data['cta_text'] ?? null,
|
||||||
|
ctaUrl: $data['cta_url'] ?? null,
|
||||||
|
title: $data['title'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar con otro ComponentContent
|
||||||
|
*
|
||||||
|
* @param ComponentContent $other
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function equals(ComponentContent $other): bool
|
||||||
|
{
|
||||||
|
return $this->messageText === $other->messageText &&
|
||||||
|
$this->ctaText === $other->ctaText &&
|
||||||
|
$this->ctaUrl === $other->ctaUrl &&
|
||||||
|
$this->title === $other->title;
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/Component/Domain/ValueObjects/ComponentName.php
Normal file
169
src/Component/Domain/ValueObjects/ComponentName.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain\ValueObjects;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentName - Value Object inmutable para nombre de componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar y validar el nombre de un componente del tema
|
||||||
|
*
|
||||||
|
* REGLAS DE NEGOCIO:
|
||||||
|
* - El nombre no puede estar vacío
|
||||||
|
* - El nombre debe tener entre 3 y 50 caracteres
|
||||||
|
* - El nombre solo puede contener letras minúsculas, números y guiones bajos
|
||||||
|
* - El nombre debe comenzar con una letra
|
||||||
|
*
|
||||||
|
* INVARIANTES:
|
||||||
|
* - Una vez creado, el nombre no puede cambiar (inmutable)
|
||||||
|
* - El nombre siempre está en formato normalizado (lowercase, sin espacios)
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $name = new ComponentName('top_bar');
|
||||||
|
* echo $name->value(); // "top_bar"
|
||||||
|
* echo $name->toString(); // "top_bar"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component\ValueObjects
|
||||||
|
*/
|
||||||
|
final class ComponentName
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Longitud mínima del nombre
|
||||||
|
*/
|
||||||
|
private const MIN_LENGTH = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longitud máxima del nombre
|
||||||
|
*/
|
||||||
|
private const MAX_LENGTH = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patrón regex para validar formato del nombre
|
||||||
|
* - Debe comenzar con letra minúscula
|
||||||
|
* - Puede contener letras minúsculas, números y guiones bajos
|
||||||
|
*/
|
||||||
|
private const PATTERN = '/^[a-z][a-z0-9_]*$/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $value Nombre del componente
|
||||||
|
* @throws InvalidComponentException Si el nombre no cumple las reglas de negocio
|
||||||
|
*/
|
||||||
|
public function __construct(private string $value)
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener el valor del nombre
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function value(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a string
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar con otro ComponentName
|
||||||
|
*
|
||||||
|
* @param ComponentName $other
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function equals(ComponentName $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar reglas de negocio del nombre
|
||||||
|
*
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
// Regla 1: No puede estar vacío
|
||||||
|
if (empty($this->value)) {
|
||||||
|
throw new InvalidComponentException('Component name cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 2: Longitud entre MIN y MAX
|
||||||
|
$length = strlen($this->value);
|
||||||
|
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
sprintf(
|
||||||
|
'Component name must be between %d and %d characters (got %d)',
|
||||||
|
self::MIN_LENGTH,
|
||||||
|
self::MAX_LENGTH,
|
||||||
|
$length
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regla 3: Formato válido (lowercase, números, guiones bajos, comienza con letra)
|
||||||
|
if (!preg_match(self::PATTERN, $this->value)) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
'Component name must start with a letter and contain only lowercase letters, numbers, and underscores'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear desde string (factory method)
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizar string a formato de nombre válido
|
||||||
|
*
|
||||||
|
* Convierte:
|
||||||
|
* - "Top Bar" → "top_bar"
|
||||||
|
* - "FOOTER-CTA" → "footer_cta"
|
||||||
|
* - " sidebar " → "sidebar"
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromNormalized(string $value): self
|
||||||
|
{
|
||||||
|
// Trim espacios
|
||||||
|
$normalized = trim($value);
|
||||||
|
|
||||||
|
// Convertir a minúsculas
|
||||||
|
$normalized = strtolower($normalized);
|
||||||
|
|
||||||
|
// Reemplazar espacios y guiones por guiones bajos
|
||||||
|
$normalized = str_replace([' ', '-'], '_', $normalized);
|
||||||
|
|
||||||
|
// Eliminar caracteres no permitidos
|
||||||
|
$normalized = preg_replace('/[^a-z0-9_]/', '', $normalized);
|
||||||
|
|
||||||
|
// Eliminar guiones bajos duplicados
|
||||||
|
$normalized = preg_replace('/_+/', '_', $normalized);
|
||||||
|
|
||||||
|
// Eliminar guiones bajos al inicio/final
|
||||||
|
$normalized = trim($normalized, '_');
|
||||||
|
|
||||||
|
return new self($normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
266
src/Component/Domain/ValueObjects/ComponentVisibility.php
Normal file
266
src/Component/Domain/ValueObjects/ComponentVisibility.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Domain\ValueObjects;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Exceptions\InvalidComponentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentVisibility - Value Object inmutable para visibilidad de componente
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Representar y validar la visibilidad de un componente en diferentes dispositivos
|
||||||
|
*
|
||||||
|
* REGLAS DE NEGOCIO:
|
||||||
|
* - Un componente puede estar habilitado o deshabilitado globalmente
|
||||||
|
* - Un componente puede tener visibilidad específica por dispositivo (desktop, tablet, mobile)
|
||||||
|
* - Si está deshabilitado globalmente, las visibilidades por dispositivo no importan
|
||||||
|
* - Al menos un dispositivo debe tener visibilidad si está habilitado globalmente
|
||||||
|
*
|
||||||
|
* CASOS DE USO:
|
||||||
|
* - Componente visible solo en desktop
|
||||||
|
* - Componente visible solo en mobile
|
||||||
|
* - Componente visible en todos los dispositivos
|
||||||
|
* - Componente completamente deshabilitado
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* // Visible en todos los dispositivos
|
||||||
|
* $visibility = ComponentVisibility::allDevices();
|
||||||
|
*
|
||||||
|
* // Visible solo en desktop
|
||||||
|
* $visibility = ComponentVisibility::desktopOnly();
|
||||||
|
*
|
||||||
|
* // Personalizado
|
||||||
|
* $visibility = new ComponentVisibility(
|
||||||
|
* enabled: true,
|
||||||
|
* visibleDesktop: true,
|
||||||
|
* visibleTablet: true,
|
||||||
|
* visibleMobile: false
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Component\ValueObjects
|
||||||
|
*/
|
||||||
|
final class ComponentVisibility
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param bool $enabled Componente habilitado globalmente
|
||||||
|
* @param bool $visibleDesktop Visible en desktop
|
||||||
|
* @param bool $visibleTablet Visible en tablet
|
||||||
|
* @param bool $visibleMobile Visible en mobile
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private bool $enabled,
|
||||||
|
private bool $visibleDesktop,
|
||||||
|
private bool $visibleTablet,
|
||||||
|
private bool $visibleMobile
|
||||||
|
) {
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si está habilitado globalmente
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si es visible en desktop
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isVisibleOnDesktop(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled && $this->visibleDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si es visible en tablet
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isVisibleOnTablet(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled && $this->visibleTablet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si es visible en mobile
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isVisibleOnMobile(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled && $this->visibleMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si es visible en al menos un dispositivo
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isVisibleOnAnyDevice(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled && (
|
||||||
|
$this->visibleDesktop ||
|
||||||
|
$this->visibleTablet ||
|
||||||
|
$this->visibleMobile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si es visible en todos los dispositivos
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isVisibleOnAllDevices(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled &&
|
||||||
|
$this->visibleDesktop &&
|
||||||
|
$this->visibleTablet &&
|
||||||
|
$this->visibleMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir a array
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => $this->enabled,
|
||||||
|
'visible_desktop' => $this->visibleDesktop,
|
||||||
|
'visible_tablet' => $this->visibleTablet,
|
||||||
|
'visible_mobile' => $this->visibleMobile
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar reglas de negocio
|
||||||
|
*
|
||||||
|
* @throws InvalidComponentException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
// Regla: Si está habilitado, al menos un dispositivo debe tener visibilidad
|
||||||
|
if ($this->enabled && !$this->visibleDesktop && !$this->visibleTablet && !$this->visibleMobile) {
|
||||||
|
throw new InvalidComponentException(
|
||||||
|
'Component is enabled but not visible on any device. At least one device must be visible.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Componente deshabilitado
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function disabled(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
enabled: false,
|
||||||
|
visibleDesktop: false,
|
||||||
|
visibleTablet: false,
|
||||||
|
visibleMobile: false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Componente visible en todos los dispositivos
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function allDevices(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
enabled: true,
|
||||||
|
visibleDesktop: true,
|
||||||
|
visibleTablet: true,
|
||||||
|
visibleMobile: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Componente visible solo en desktop
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function desktopOnly(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
enabled: true,
|
||||||
|
visibleDesktop: true,
|
||||||
|
visibleTablet: false,
|
||||||
|
visibleMobile: false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Componente visible solo en mobile
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function mobileOnly(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
enabled: true,
|
||||||
|
visibleDesktop: false,
|
||||||
|
visibleTablet: false,
|
||||||
|
visibleMobile: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Componente visible solo en tablet
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function tabletOnly(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
enabled: true,
|
||||||
|
visibleDesktop: false,
|
||||||
|
visibleTablet: true,
|
||||||
|
visibleMobile: false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear desde array
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
enabled: (bool) ($data['enabled'] ?? false),
|
||||||
|
visibleDesktop: (bool) ($data['visible_desktop'] ?? true),
|
||||||
|
visibleTablet: (bool) ($data['visible_tablet'] ?? true),
|
||||||
|
visibleMobile: (bool) ($data['visible_mobile'] ?? true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar con otro ComponentVisibility
|
||||||
|
*
|
||||||
|
* @param ComponentVisibility $other
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function equals(ComponentVisibility $other): bool
|
||||||
|
{
|
||||||
|
return $this->enabled === $other->enabled &&
|
||||||
|
$this->visibleDesktop === $other->visibleDesktop &&
|
||||||
|
$this->visibleTablet === $other->visibleTablet &&
|
||||||
|
$this->visibleMobile === $other->visibleMobile;
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/Component/Infrastructure/API/WordPress/AjaxController.php
Normal file
215
src/Component/Infrastructure/API/WordPress/AjaxController.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\API\WordPress;
|
||||||
|
|
||||||
|
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentUseCase;
|
||||||
|
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentRequest;
|
||||||
|
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentUseCase;
|
||||||
|
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentRequest;
|
||||||
|
use ROITheme\Component\Application\UseCases\DeleteComponent\DeleteComponentUseCase;
|
||||||
|
use ROITheme\Component\Application\UseCases\DeleteComponent\DeleteComponentRequest;
|
||||||
|
use ROITheme\Component\Infrastructure\DI\DIContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AjaxController - Endpoints AJAX de WordPress
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Manejar HTTP (request/response)
|
||||||
|
*
|
||||||
|
* PRINCIPIOS:
|
||||||
|
* - Single Responsibility: Solo maneja HTTP, delega lógica a Use Cases
|
||||||
|
* - Dependency Inversion: Depende de Use Cases (Application)
|
||||||
|
*
|
||||||
|
* SEGURIDAD:
|
||||||
|
* - Verifica nonce
|
||||||
|
* - Verifica capabilities (manage_options)
|
||||||
|
* - Sanitiza inputs
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\API\WordPress
|
||||||
|
*/
|
||||||
|
final class AjaxController
|
||||||
|
{
|
||||||
|
private const NONCE_ACTION = 'roi_theme_admin_nonce';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private DIContainer $container
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar hooks de WordPress
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_action('wp_ajax_roi_theme_save_component', [$this, 'saveComponent']);
|
||||||
|
add_action('wp_ajax_roi_theme_get_component', [$this, 'getComponent']);
|
||||||
|
add_action('wp_ajax_roi_theme_delete_component', [$this, 'deleteComponent']);
|
||||||
|
add_action('wp_ajax_roi_theme_sync_schema', [$this, 'syncSchema']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint: Guardar componente
|
||||||
|
*
|
||||||
|
* POST /wp-admin/admin-ajax.php?action=roi_theme_save_component
|
||||||
|
* Body: { nonce, component_name, data }
|
||||||
|
*
|
||||||
|
* @return void (usa wp_send_json_*)
|
||||||
|
*/
|
||||||
|
public function saveComponent(): void
|
||||||
|
{
|
||||||
|
// 1. Seguridad
|
||||||
|
if (!$this->verifySecurity()) {
|
||||||
|
wp_send_json_error([
|
||||||
|
'message' => 'Security check failed'
|
||||||
|
], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sanitizar input
|
||||||
|
$componentName = sanitize_key($_POST['component_name'] ?? '');
|
||||||
|
$data = $_POST['data'] ?? [];
|
||||||
|
|
||||||
|
if (empty($componentName)) {
|
||||||
|
wp_send_json_error([
|
||||||
|
'message' => 'Component name is required'
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Crear Request DTO
|
||||||
|
$request = new SaveComponentRequest($componentName, $data);
|
||||||
|
|
||||||
|
// 4. Ejecutar Use Case
|
||||||
|
$useCase = new SaveComponentUseCase(
|
||||||
|
$this->container->getComponentRepository(),
|
||||||
|
$this->container->getValidationService(),
|
||||||
|
$this->container->getCacheService()
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $useCase->execute($request);
|
||||||
|
|
||||||
|
// 5. Respuesta HTTP
|
||||||
|
if ($response->isSuccess()) {
|
||||||
|
wp_send_json_success($response->getData());
|
||||||
|
} else {
|
||||||
|
wp_send_json_error([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $response->getErrors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint: Obtener componente
|
||||||
|
*
|
||||||
|
* GET /wp-admin/admin-ajax.php?action=roi_theme_get_component&component_name=xxx
|
||||||
|
*/
|
||||||
|
public function getComponent(): void
|
||||||
|
{
|
||||||
|
if (!$this->verifySecurity()) {
|
||||||
|
wp_send_json_error(['message' => 'Security check failed'], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentName = sanitize_key($_GET['component_name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($componentName)) {
|
||||||
|
wp_send_json_error(['message' => 'Component name is required'], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = new GetComponentRequest($componentName);
|
||||||
|
|
||||||
|
$useCase = new GetComponentUseCase(
|
||||||
|
$this->container->getComponentRepository(),
|
||||||
|
$this->container->getCacheService()
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $useCase->execute($request);
|
||||||
|
|
||||||
|
if ($response->isSuccess()) {
|
||||||
|
wp_send_json_success($response->getData());
|
||||||
|
} else {
|
||||||
|
wp_send_json_error(['message' => $response->getError()], 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint: Eliminar componente
|
||||||
|
*
|
||||||
|
* POST /wp-admin/admin-ajax.php?action=roi_theme_delete_component
|
||||||
|
* Body: { nonce, component_name }
|
||||||
|
*/
|
||||||
|
public function deleteComponent(): void
|
||||||
|
{
|
||||||
|
if (!$this->verifySecurity()) {
|
||||||
|
wp_send_json_error(['message' => 'Security check failed'], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentName = sanitize_key($_POST['component_name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($componentName)) {
|
||||||
|
wp_send_json_error(['message' => 'Component name is required'], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = new DeleteComponentRequest($componentName);
|
||||||
|
|
||||||
|
$useCase = new DeleteComponentUseCase(
|
||||||
|
$this->container->getComponentRepository(),
|
||||||
|
$this->container->getCacheService()
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $useCase->execute($request);
|
||||||
|
|
||||||
|
if ($response->isSuccess()) {
|
||||||
|
wp_send_json_success(['message' => $response->getMessage()]);
|
||||||
|
} else {
|
||||||
|
wp_send_json_error(['message' => $response->getError()], 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint: Sincronizar schemas
|
||||||
|
*
|
||||||
|
* POST /wp-admin/admin-ajax.php?action=roi_theme_sync_schema
|
||||||
|
*/
|
||||||
|
public function syncSchema(): void
|
||||||
|
{
|
||||||
|
if (!$this->verifySecurity()) {
|
||||||
|
wp_send_json_error(['message' => 'Security check failed'], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncService = $this->container->getSchemaSyncService();
|
||||||
|
$result = $syncService->syncAll();
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
wp_send_json_success($result['data']);
|
||||||
|
} else {
|
||||||
|
wp_send_json_error(['message' => $result['error']], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar seguridad (nonce + capabilities)
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function verifySecurity(): bool
|
||||||
|
{
|
||||||
|
// Verificar nonce
|
||||||
|
$nonce = $_REQUEST['nonce'] ?? '';
|
||||||
|
|
||||||
|
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permisos (solo administradores)
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/Component/Infrastructure/API/WordPress/MigrationCommand.php
Normal file
189
src/Component/Infrastructure/API/WordPress/MigrationCommand.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\API\WordPress;
|
||||||
|
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\DatabaseMigrator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-CLI Command para Migración de Base de Datos
|
||||||
|
*
|
||||||
|
* Responsabilidad: Interfaz CLI para ejecutar migración de BD
|
||||||
|
*
|
||||||
|
* COMANDOS DISPONIBLES:
|
||||||
|
* - wp roi-theme migrate : Ejecutar migración real
|
||||||
|
* - wp roi-theme migrate --dry-run : Simular migración sin cambios
|
||||||
|
* - wp roi-theme cleanup-backup : Eliminar tablas de backup
|
||||||
|
*
|
||||||
|
* FLUJO RECOMENDADO:
|
||||||
|
* 1. Crear backup de BD manualmente
|
||||||
|
* 2. Ejecutar: wp roi-theme migrate --dry-run (simulación)
|
||||||
|
* 3. Ejecutar: wp roi-theme migrate (migración real)
|
||||||
|
* 4. Validar funcionamiento por 7-30 días
|
||||||
|
* 5. Ejecutar: wp roi-theme cleanup-backup
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```bash
|
||||||
|
* # Dry-run (simulación)
|
||||||
|
* wp roi-theme migrate --dry-run
|
||||||
|
*
|
||||||
|
* # Migración real
|
||||||
|
* wp roi-theme migrate
|
||||||
|
*
|
||||||
|
* # Limpiar backup (después de validar)
|
||||||
|
* wp roi-theme cleanup-backup
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
final class MigrationCommand
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ejecutar migración de BD legacy a Clean Architecture
|
||||||
|
*
|
||||||
|
* ## OPCIONES
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Solo mostrar qué se haría sin ejecutar cambios reales
|
||||||
|
*
|
||||||
|
* ## EJEMPLOS
|
||||||
|
*
|
||||||
|
* # Simular migración
|
||||||
|
* wp roi-theme migrate --dry-run
|
||||||
|
*
|
||||||
|
* # Ejecutar migración real
|
||||||
|
* wp roi-theme migrate
|
||||||
|
*
|
||||||
|
* @param array $args Argumentos posicionales
|
||||||
|
* @param array $assoc_args Argumentos asociativos (--flags)
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function migrate(array $args, array $assoc_args): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$dry_run = isset($assoc_args['dry-run']);
|
||||||
|
|
||||||
|
if ($dry_run) {
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::line('🔍 MODO DRY-RUN: No se harán cambios reales');
|
||||||
|
\WP_CLI::line('');
|
||||||
|
}
|
||||||
|
|
||||||
|
\WP_CLI::line('🚀 Iniciando migración de base de datos...');
|
||||||
|
\WP_CLI::line('');
|
||||||
|
|
||||||
|
$migrator = new DatabaseMigrator($wpdb);
|
||||||
|
|
||||||
|
if ($dry_run) {
|
||||||
|
// Simular migración
|
||||||
|
$this->simulateMigration($migrator);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar migración real
|
||||||
|
$start_time = microtime(true);
|
||||||
|
$result = $migrator->migrate();
|
||||||
|
$end_time = microtime(true);
|
||||||
|
$duration = round($end_time - $start_time, 2);
|
||||||
|
|
||||||
|
\WP_CLI::line('');
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
\WP_CLI::success('✅ ' . $result['message']);
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::line('📊 Estadísticas de Migración:');
|
||||||
|
\WP_CLI::line(' ├─ Components migrados: ' . ($result['stats']['components']['migrated'] ?? 0));
|
||||||
|
\WP_CLI::line(' ├─ Defaults migrados: ' . ($result['stats']['defaults']['migrated'] ?? 0));
|
||||||
|
\WP_CLI::line(' ├─ Tiempo de ejecución: ' . $duration . 's');
|
||||||
|
\WP_CLI::line(' └─ Validación: ' . $result['stats']['validation']['message']);
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::line('⚠️ IMPORTANTE: Tablas legacy respaldadas con sufijo _backup');
|
||||||
|
\WP_CLI::line(' Validar funcionamiento por 7-30 días antes de limpiar backup.');
|
||||||
|
\WP_CLI::line(' Comando para limpiar: wp roi-theme cleanup-backup');
|
||||||
|
\WP_CLI::line('');
|
||||||
|
} else {
|
||||||
|
\WP_CLI::error('❌ ' . $result['message']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpiar tablas de backup después de validar migración
|
||||||
|
*
|
||||||
|
* IMPORTANTE: Solo ejecutar después de validar que todo funciona correctamente
|
||||||
|
*
|
||||||
|
* ## EJEMPLOS
|
||||||
|
*
|
||||||
|
* wp roi-theme cleanup-backup
|
||||||
|
*
|
||||||
|
* @param array $args Argumentos posicionales
|
||||||
|
* @param array $assoc_args Argumentos asociativos
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function cleanup_backup(array $args, array $assoc_args): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::confirm(
|
||||||
|
'⚠️ ¿Estás seguro de eliminar las tablas de backup? Esta acción es IRREVERSIBLE.',
|
||||||
|
$assoc_args
|
||||||
|
);
|
||||||
|
|
||||||
|
$migrator = new DatabaseMigrator($wpdb);
|
||||||
|
$migrator->cleanupBackup();
|
||||||
|
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::success('✅ Tablas de backup eliminadas correctamente');
|
||||||
|
\WP_CLI::line('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simular migración sin hacer cambios reales
|
||||||
|
*
|
||||||
|
* @param DatabaseMigrator $migrator Instancia del migrador
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function simulateMigration(DatabaseMigrator $migrator): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
\WP_CLI::line('Verificando tablas legacy...');
|
||||||
|
|
||||||
|
// Verificar existencia de tablas
|
||||||
|
$legacy_components = $wpdb->prefix . 'roi_theme_components';
|
||||||
|
$legacy_defaults = $wpdb->prefix . 'roi_theme_components_defaults';
|
||||||
|
|
||||||
|
$components_exist = $wpdb->get_var("SHOW TABLES LIKE '{$legacy_components}'") === $legacy_components;
|
||||||
|
$defaults_exist = $wpdb->get_var("SHOW TABLES LIKE '{$legacy_defaults}'") === $legacy_defaults;
|
||||||
|
|
||||||
|
if (!$components_exist || !$defaults_exist) {
|
||||||
|
\WP_CLI::error('Tablas legacy no encontradas. No hay nada que migrar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
\WP_CLI::line('✓ Tablas legacy encontradas');
|
||||||
|
|
||||||
|
// Contar registros
|
||||||
|
$components_count = $wpdb->get_var("SELECT COUNT(*) FROM {$legacy_components}");
|
||||||
|
$defaults_count = $wpdb->get_var("SELECT COUNT(*) FROM {$legacy_defaults}");
|
||||||
|
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::line('📊 Datos a migrar:');
|
||||||
|
\WP_CLI::line(" ├─ Components: {$components_count} registros");
|
||||||
|
\WP_CLI::line(" └─ Defaults: {$defaults_count} registros");
|
||||||
|
\WP_CLI::line('');
|
||||||
|
\WP_CLI::line('Operaciones que se ejecutarían:');
|
||||||
|
\WP_CLI::line(' 1. Crear tablas v2 con nueva estructura (config_group)');
|
||||||
|
\WP_CLI::line(' 2. Migrar ' . ($components_count + $defaults_count) . ' registros');
|
||||||
|
\WP_CLI::line(' 3. Validar integridad de datos');
|
||||||
|
\WP_CLI::line(' 4. Renombrar tablas (legacy → _backup, v2 → producción)');
|
||||||
|
\WP_CLI::line('');
|
||||||
|
|
||||||
|
\WP_CLI::success('✅ Simulación completada. Ejecutar sin --dry-run para migración real.');
|
||||||
|
\WP_CLI::line('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar comando WP-CLI
|
||||||
|
if (defined('WP_CLI') && WP_CLI) {
|
||||||
|
\WP_CLI::add_command('roi-theme', MigrationCommand::class);
|
||||||
|
}
|
||||||
320
src/Component/Infrastructure/Adapters/LegacyDBManagerAdapter.php
Normal file
320
src/Component/Infrastructure/Adapters/LegacyDBManagerAdapter.php
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Adapters;
|
||||||
|
|
||||||
|
use ROITheme\Component\Infrastructure\DI\DIContainer;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LegacyDBManagerAdapter - Adapter para ROI_DB_Manager deprecated
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Traducir llamadas legacy a la nueva arquitectura
|
||||||
|
*
|
||||||
|
* PATRÓN: Adapter Pattern
|
||||||
|
* - Interfaz antigua (ROI_DB_Manager methods)
|
||||||
|
* - Implementación nueva (Clean Architecture repositories)
|
||||||
|
*
|
||||||
|
* Este adapter mantiene compatibilidad backward mientras se migra el código
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Adapters
|
||||||
|
*/
|
||||||
|
final class LegacyDBManagerAdapter
|
||||||
|
{
|
||||||
|
private ComponentRepositoryInterface $componentRepository;
|
||||||
|
private ComponentDefaultsRepositoryInterface $defaultsRepository;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// Get repositories from DI Container
|
||||||
|
$schemasPath = get_template_directory() . '/schemas';
|
||||||
|
$container = new DIContainer($wpdb, $schemasPath);
|
||||||
|
|
||||||
|
$this->componentRepository = $container->getComponentRepository();
|
||||||
|
$this->defaultsRepository = $container->getDefaultsRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save component configuration (legacy method)
|
||||||
|
*
|
||||||
|
* Adapta save_config() legacy a WordPressComponentRepository::save()
|
||||||
|
*
|
||||||
|
* @param string $component_name Component name
|
||||||
|
* @param string $config_key Configuration key
|
||||||
|
* @param mixed $config_value Configuration value
|
||||||
|
* @param string $data_type Data type
|
||||||
|
* @param string|null $version Schema version
|
||||||
|
* @param string $table_type Table type (components or defaults)
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function save_config(
|
||||||
|
string $component_name,
|
||||||
|
string $config_key,
|
||||||
|
$config_value,
|
||||||
|
string $data_type = 'string',
|
||||||
|
?string $version = null,
|
||||||
|
string $table_type = 'components'
|
||||||
|
): bool {
|
||||||
|
try {
|
||||||
|
$componentNameVO = new ComponentName($component_name);
|
||||||
|
|
||||||
|
if ($table_type === 'defaults') {
|
||||||
|
// Save to defaults repository
|
||||||
|
$config = $this->defaultsRepository->exists($componentNameVO)
|
||||||
|
? $this->defaultsRepository->getByName($componentNameVO)
|
||||||
|
: ComponentConfiguration::fromArray([]);
|
||||||
|
|
||||||
|
$data = $config->toArray();
|
||||||
|
$data[$config_key] = $this->convertValue($config_value, $data_type);
|
||||||
|
|
||||||
|
$newConfig = ComponentConfiguration::fromArray($data);
|
||||||
|
$this->defaultsRepository->save($componentNameVO, $newConfig);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to component repository
|
||||||
|
$component = $this->componentRepository->findByName($componentNameVO);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
// Create new component with this configuration
|
||||||
|
$data = [
|
||||||
|
'visibility' => ['is_enabled' => true],
|
||||||
|
'content' => [$config_key => $this->convertValue($config_value, $data_type)],
|
||||||
|
'styles' => [],
|
||||||
|
'config' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
$newComponent = new \ROITheme\Domain\Component\Component(
|
||||||
|
$component_name,
|
||||||
|
$component_name,
|
||||||
|
$data
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->componentRepository->save($newComponent);
|
||||||
|
} else {
|
||||||
|
// Update existing component
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
// Determinar en qué sección guardar
|
||||||
|
$section = $this->determineSection($config_key);
|
||||||
|
if (!isset($data[$section])) {
|
||||||
|
$data[$section] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data[$section][$config_key] = $this->convertValue($config_value, $data_type);
|
||||||
|
|
||||||
|
$updatedComponent = $component->withData($data);
|
||||||
|
$this->componentRepository->save($updatedComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log error but maintain backward compatibility
|
||||||
|
error_log("LegacyDBManagerAdapter::save_config error: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component configuration (legacy method)
|
||||||
|
*
|
||||||
|
* Adapta get_config() legacy a WordPressComponentRepository::findByName()
|
||||||
|
*
|
||||||
|
* @param string $component_name Component name
|
||||||
|
* @param string|null $config_key Specific configuration key (null for all)
|
||||||
|
* @param string $table_type Table type (components or defaults)
|
||||||
|
* @return mixed Configuration value(s) or null
|
||||||
|
*/
|
||||||
|
public function get_config(
|
||||||
|
string $component_name,
|
||||||
|
?string $config_key = null,
|
||||||
|
string $table_type = 'components'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
$componentNameVO = new ComponentName($component_name);
|
||||||
|
|
||||||
|
if ($table_type === 'defaults') {
|
||||||
|
if (!$this->defaultsRepository->exists($componentNameVO)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->defaultsRepository->getByName($componentNameVO);
|
||||||
|
$data = $config->toArray();
|
||||||
|
|
||||||
|
return $config_key ? ($data[$config_key] ?? null) : $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get from component repository
|
||||||
|
$component = $this->componentRepository->findByName($componentNameVO);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if ($config_key === null) {
|
||||||
|
// Return all configuration
|
||||||
|
return $this->flattenComponentData($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for key in all sections
|
||||||
|
foreach (['visibility', 'content', 'styles', 'config'] as $section) {
|
||||||
|
if (isset($data[$section][$config_key])) {
|
||||||
|
return $data[$section][$config_key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("LegacyDBManagerAdapter::get_config error: " . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete component configuration (legacy method)
|
||||||
|
*
|
||||||
|
* Adapta delete_config() legacy a WordPressComponentRepository::delete()
|
||||||
|
*
|
||||||
|
* @param string $component_name Component name
|
||||||
|
* @param string $table_type Table type (components or defaults)
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function delete_config(string $component_name, string $table_type = 'components'): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$componentNameVO = new ComponentName($component_name);
|
||||||
|
|
||||||
|
if ($table_type === 'defaults') {
|
||||||
|
return $this->defaultsRepository->delete($componentNameVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->componentRepository->delete($componentNameVO);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("LegacyDBManagerAdapter::delete_config error: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component by table (legacy method)
|
||||||
|
*
|
||||||
|
* @param string $component_name Component name
|
||||||
|
* @param string $table_type Table type
|
||||||
|
* @return array|null Component data or null
|
||||||
|
*/
|
||||||
|
public function get_component_by_table(string $component_name, string $table_type = 'components'): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$componentNameVO = new ComponentName($component_name);
|
||||||
|
|
||||||
|
if ($table_type === 'defaults') {
|
||||||
|
if (!$this->defaultsRepository->exists($componentNameVO)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->defaultsRepository->getByName($componentNameVO);
|
||||||
|
return [
|
||||||
|
'component_name' => $component_name,
|
||||||
|
'default_schema' => json_encode($config->toArray()),
|
||||||
|
'created_at' => '',
|
||||||
|
'updated_at' => ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$component = $this->componentRepository->findByName($componentNameVO);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'component_name' => $component_name,
|
||||||
|
'configuration' => json_encode($component->getData()),
|
||||||
|
'is_enabled' => $component->isEnabled() ? 1 : 0,
|
||||||
|
'created_at' => '',
|
||||||
|
'updated_at' => ''
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("LegacyDBManagerAdapter::get_component_by_table error: " . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert value to appropriate type
|
||||||
|
*
|
||||||
|
* @param mixed $value Value to convert
|
||||||
|
* @param string $type Target type
|
||||||
|
* @return mixed Converted value
|
||||||
|
*/
|
||||||
|
private function convertValue($value, string $type)
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case 'boolean':
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
case 'integer':
|
||||||
|
return (int) $value;
|
||||||
|
case 'array':
|
||||||
|
return is_array($value) ? $value : json_decode($value, true);
|
||||||
|
case 'string':
|
||||||
|
default:
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which section a config key belongs to
|
||||||
|
*
|
||||||
|
* @param string $key Configuration key
|
||||||
|
* @return string Section name
|
||||||
|
*/
|
||||||
|
private function determineSection(string $key): string
|
||||||
|
{
|
||||||
|
// Visibility keys
|
||||||
|
if (in_array($key, ['is_enabled', 'sticky', 'show_on_mobile', 'show_on_desktop'])) {
|
||||||
|
return 'visibility';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style keys
|
||||||
|
if (strpos($key, 'color') !== false || strpos($key, 'font') !== false || strpos($key, 'size') !== false) {
|
||||||
|
return 'styles';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config keys
|
||||||
|
if (in_array($key, ['auto_close', 'close_delay', 'animation'])) {
|
||||||
|
return 'config';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to content
|
||||||
|
return 'content';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten component data for legacy compatibility
|
||||||
|
*
|
||||||
|
* @param array $data Component data
|
||||||
|
* @return array Flattened data
|
||||||
|
*/
|
||||||
|
private function flattenComponentData(array $data): array
|
||||||
|
{
|
||||||
|
$flattened = [];
|
||||||
|
|
||||||
|
foreach ($data as $section => $values) {
|
||||||
|
if (is_array($values)) {
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
$flattened[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $flattened;
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/Component/Infrastructure/Adapters/README.md
Normal file
350
src/Component/Infrastructure/Adapters/README.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# Legacy Adapters - Patrón Adapter para Compatibilidad
|
||||||
|
|
||||||
|
Este directorio contiene **Adapters** que mantienen compatibilidad backward con código legacy mientras redirigen internamente a la nueva arquitectura Clean Architecture.
|
||||||
|
|
||||||
|
## Tabla de Contenidos
|
||||||
|
|
||||||
|
1. [¿Qué es un Adapter?](#qué-es-un-adapter)
|
||||||
|
2. [¿Por qué usar Adapters?](#por-qué-usar-adapters)
|
||||||
|
3. [Adapters Disponibles](#adapters-disponibles)
|
||||||
|
4. [Cómo Migrar del Código Legacy](#cómo-migrar-del-código-legacy)
|
||||||
|
5. [Ejemplos de Uso](#ejemplos-de-uso)
|
||||||
|
6. [Deprecation Strategy](#deprecation-strategy)
|
||||||
|
7. [Timeline de Eliminación](#timeline-de-eliminación)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ¿Qué es un Adapter?
|
||||||
|
|
||||||
|
Un **Adapter** es un patrón de diseño que permite que interfaces incompatibles trabajen juntas. En este contexto:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Legacy Code │ ← API antigua (ROI_DB_Manager)
|
||||||
|
│ (deprecated) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ usa
|
||||||
|
↓
|
||||||
|
┌─────────────────┐
|
||||||
|
│ ADAPTER │ ← Traduce llamadas legacy → nueva arquitectura
|
||||||
|
│ LegacyDBManager │
|
||||||
|
│ Adapter │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ delega a
|
||||||
|
↓
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Clean Arch │ ← Arquitectura limpia (Repositories, UseCases)
|
||||||
|
│ Repositories │
|
||||||
|
│ UseCases │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## ¿Por qué usar Adapters?
|
||||||
|
|
||||||
|
### Ventajas
|
||||||
|
|
||||||
|
✅ **Compatibilidad Backward**: El código existente sigue funcionando sin cambios
|
||||||
|
✅ **Migración Gradual**: Puedes migrar componente por componente
|
||||||
|
✅ **Zero Downtime**: No hay riesgo de romper la aplicación
|
||||||
|
✅ **Logging de Uso**: Trackea qué código legacy aún se está usando
|
||||||
|
✅ **Testing Facilitado**: Puedes testear nueva y vieja arquitectura en paralelo
|
||||||
|
|
||||||
|
### Estrategia de Migración
|
||||||
|
|
||||||
|
1. **Fase 1** (actual): Código legacy deprecated pero funcional vía adapters
|
||||||
|
2. **Fase 2** (v2.1-2.9): Migración gradual del código a nueva arquitectura
|
||||||
|
3. **Fase 3** (v3.0): Eliminación completa del código legacy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adapters Disponibles
|
||||||
|
|
||||||
|
### LegacyDBManagerAdapter
|
||||||
|
|
||||||
|
**Ubicación**: `src/Infrastructure/Adapters/LegacyDBManagerAdapter.php`
|
||||||
|
|
||||||
|
**Propósito**: Adapta la API antigua de `ROI_DB_Manager` a los nuevos Repositories de Clean Architecture.
|
||||||
|
|
||||||
|
**Métodos adaptados**:
|
||||||
|
|
||||||
|
| Método Legacy | Nuevo Equivalente |
|
||||||
|
|--------------|-------------------|
|
||||||
|
| `save_config($name, $key, $value, $type, $version, $table)` | `ComponentRepository::save(Component)` |
|
||||||
|
| `get_config($name, $key, $table)` | `ComponentRepository::findByName(ComponentName)` |
|
||||||
|
| `delete_config($name, $key, $table)` | `ComponentRepository::delete(ComponentName)` |
|
||||||
|
|
||||||
|
**Cómo funciona**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// OLD WAY (deprecated pero funciona vía adapter)
|
||||||
|
$db_manager = new ROI_DB_Manager();
|
||||||
|
$db_manager->save_config('navbar', 'background_color', '#000000', 'string');
|
||||||
|
|
||||||
|
// ↓ El adapter internamente hace esto ↓
|
||||||
|
|
||||||
|
// NEW WAY (Clean Architecture)
|
||||||
|
$container = new DIContainer($wpdb, $schemasPath);
|
||||||
|
$repository = $container->getComponentRepository();
|
||||||
|
|
||||||
|
$componentName = new ComponentName('navbar');
|
||||||
|
$component = $repository->findByName($componentName);
|
||||||
|
|
||||||
|
$data = $component->getData();
|
||||||
|
$data['styles']['background_color'] = '#000000';
|
||||||
|
|
||||||
|
$updatedComponent = $component->withData($data);
|
||||||
|
$repository->save($updatedComponent);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cómo Migrar del Código Legacy
|
||||||
|
|
||||||
|
### Paso 1: Identificar Uso Legacy
|
||||||
|
|
||||||
|
Usa el script de detección:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/detect-legacy-usage.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto generará un reporte de dónde se está usando código deprecated.
|
||||||
|
|
||||||
|
### Paso 2: Entender el Mapeo
|
||||||
|
|
||||||
|
#### Guardar Configuración
|
||||||
|
|
||||||
|
**Antes (Legacy)**:
|
||||||
|
```php
|
||||||
|
$db_manager = new ROI_DB_Manager();
|
||||||
|
$result = $db_manager->save_config(
|
||||||
|
'top_notification_bar', // component_name
|
||||||
|
'text', // config_key
|
||||||
|
'Welcome!', // config_value
|
||||||
|
'string', // data_type
|
||||||
|
'1.0', // version
|
||||||
|
'components' // table_type
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Después (Clean Architecture)**:
|
||||||
|
```php
|
||||||
|
use ROITheme\Infrastructure\DI\DIContainer;
|
||||||
|
use ROITheme\Domain\Component\ValueObjects\ComponentName;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
|
||||||
|
$repository = $container->getComponentRepository();
|
||||||
|
|
||||||
|
$componentName = new ComponentName('top_notification_bar');
|
||||||
|
$component = $repository->findByName($componentName);
|
||||||
|
|
||||||
|
if ($component) {
|
||||||
|
$data = $component->getData();
|
||||||
|
$data['content']['text'] = 'Welcome!';
|
||||||
|
|
||||||
|
$updatedComponent = $component->withData($data);
|
||||||
|
$repository->save($updatedComponent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Obtener Configuración
|
||||||
|
|
||||||
|
**Antes (Legacy)**:
|
||||||
|
```php
|
||||||
|
$db_manager = new ROI_DB_Manager();
|
||||||
|
$text = $db_manager->get_config('top_notification_bar', 'text');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Después (Clean Architecture)**:
|
||||||
|
```php
|
||||||
|
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
|
||||||
|
$repository = $container->getComponentRepository();
|
||||||
|
|
||||||
|
$componentName = new ComponentName('top_notification_bar');
|
||||||
|
$component = $repository->findByName($componentName);
|
||||||
|
|
||||||
|
if ($component) {
|
||||||
|
$data = $component->getData();
|
||||||
|
$text = $data['content']['text'] ?? '';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Eliminar Configuración
|
||||||
|
|
||||||
|
**Antes (Legacy)**:
|
||||||
|
```php
|
||||||
|
$db_manager = new ROI_DB_Manager();
|
||||||
|
$db_manager->delete_config('navbar', null, 'components');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Después (Clean Architecture)**:
|
||||||
|
```php
|
||||||
|
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
|
||||||
|
$repository = $container->getComponentRepository();
|
||||||
|
|
||||||
|
$componentName = new ComponentName('navbar');
|
||||||
|
$repository->delete($componentName);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplos de Uso
|
||||||
|
|
||||||
|
### Ejemplo 1: Migrar save_config a Component::save()
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ❌ OLD - Deprecated
|
||||||
|
$db_manager = new ROI_DB_Manager();
|
||||||
|
$db_manager->save_config('navbar', 'logo_url', 'https://example.com/logo.png', 'string');
|
||||||
|
|
||||||
|
// ✅ NEW - Recomendado
|
||||||
|
use ROITheme\Infrastructure\DI\DIContainer;
|
||||||
|
use ROITheme\Domain\Component\ValueObjects\ComponentName;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
|
||||||
|
$repository = $container->getComponentRepository();
|
||||||
|
|
||||||
|
$name = new ComponentName('navbar');
|
||||||
|
$component = $repository->findByName($name);
|
||||||
|
|
||||||
|
if ($component) {
|
||||||
|
$data = $component->getData();
|
||||||
|
$data['content']['logo_url'] = 'https://example.com/logo.png';
|
||||||
|
$repository->save($component->withData($data));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo 2: Migrar get_config a Component::getData()
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ❌ OLD - Deprecated
|
||||||
|
$db_manager = new ROI_DB_Manager();
|
||||||
|
$isSticky = $db_manager->get_config('navbar', 'is_sticky');
|
||||||
|
|
||||||
|
// ✅ NEW - Recomendado
|
||||||
|
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
|
||||||
|
$repository = $container->getComponentRepository();
|
||||||
|
|
||||||
|
$name = new ComponentName('navbar');
|
||||||
|
$component = $repository->findByName($name);
|
||||||
|
|
||||||
|
$isSticky = false;
|
||||||
|
if ($component) {
|
||||||
|
$data = $component->getData();
|
||||||
|
$isSticky = $data['config']['is_sticky'] ?? false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo 3: Usar Dependency Injection
|
||||||
|
|
||||||
|
Para código más limpio, inyecta el repository en lugar de crearlo cada vez:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class MyComponentController
|
||||||
|
{
|
||||||
|
private ComponentRepositoryInterface $repository;
|
||||||
|
|
||||||
|
public function __construct(ComponentRepositoryInterface $repository)
|
||||||
|
{
|
||||||
|
$this->repository = $repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateNavbarLogo(string $logoUrl): void
|
||||||
|
{
|
||||||
|
$name = new ComponentName('navbar');
|
||||||
|
$component = $this->repository->findByName($name);
|
||||||
|
|
||||||
|
if ($component) {
|
||||||
|
$data = $component->getData();
|
||||||
|
$data['content']['logo_url'] = $logoUrl;
|
||||||
|
$this->repository->save($component->withData($data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uso
|
||||||
|
global $wpdb;
|
||||||
|
$container = new DIContainer($wpdb, get_template_directory() . '/schemas');
|
||||||
|
$controller = new MyComponentController($container->getComponentRepository());
|
||||||
|
$controller->updateNavbarLogo('https://example.com/new-logo.png');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deprecation Strategy
|
||||||
|
|
||||||
|
### Logging Automático
|
||||||
|
|
||||||
|
Cuando usas código legacy, automáticamente se registra en:
|
||||||
|
|
||||||
|
1. **Base de datos**: Tabla `wp_roi_theme_deprecation_log`
|
||||||
|
2. **Archivo log**: `wp-content/uploads/roi-theme-logs/deprecation-YYYY-MM-DD.log`
|
||||||
|
|
||||||
|
### Ver Estadísticas de Uso
|
||||||
|
|
||||||
|
```php
|
||||||
|
use ROITheme\Infrastructure\Logging\DeprecationLogger;
|
||||||
|
|
||||||
|
$logger = DeprecationLogger::getInstance();
|
||||||
|
|
||||||
|
// Top 10 métodos deprecated más usados
|
||||||
|
$topDeprecated = $logger->getTopDeprecated(10);
|
||||||
|
|
||||||
|
// Uso en los últimos 7 días
|
||||||
|
$stats = $logger->getStatistics(7);
|
||||||
|
|
||||||
|
// Limpiar logs antiguos (más de 30 días)
|
||||||
|
$deleted = $logger->clearOldLogs(30);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warnings en WordPress
|
||||||
|
|
||||||
|
Cuando WP_DEBUG está activo, verás warnings como:
|
||||||
|
|
||||||
|
```
|
||||||
|
Deprecated: ROI_DB_Manager::save_config is deprecated since version 2.0.0!
|
||||||
|
Use ROITheme\Application\UseCases\SaveComponent\SaveComponentUseCase::execute() instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline de Eliminación
|
||||||
|
|
||||||
|
| Versión | Acción | Fecha Estimada |
|
||||||
|
|---------|--------|----------------|
|
||||||
|
| **2.0.0** | ✅ Código legacy marcado como @deprecated | Actual |
|
||||||
|
| **2.1.0** | ⚠️ Warnings visibles con WP_DEBUG | +1 mes |
|
||||||
|
| **2.5.0** | ⚠️ Warnings visibles siempre (no solo DEBUG) | +3 meses |
|
||||||
|
| **2.9.0** | ⚠️ Última versión con código legacy funcional | +6 meses |
|
||||||
|
| **3.0.0** | ❌ Código legacy eliminado completamente | +12 meses |
|
||||||
|
|
||||||
|
### Recomendaciones
|
||||||
|
|
||||||
|
1. **Migra YA** si estás escribiendo código nuevo
|
||||||
|
2. **Migra en 3 meses** si tu código actual usa métodos deprecated
|
||||||
|
3. **Migra en 6 meses** como máximo - después se romperá
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recursos Adicionales
|
||||||
|
|
||||||
|
- [Guía de Migración Completa](../../docs/MIGRATION_GUIDE.md)
|
||||||
|
- [Deprecation Timeline](../../docs/DEPRECATION_TIMELINE.md)
|
||||||
|
- [Clean Architecture Documentation](../../docs/CLEAN_ARCHITECTURE.md)
|
||||||
|
- [Testing Guide](../../docs/TESTING.md)
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
Si tienes preguntas sobre la migración:
|
||||||
|
|
||||||
|
1. Revisa la [Guía de Migración](../../docs/MIGRATION_GUIDE.md)
|
||||||
|
2. Ejecuta `php scripts/detect-legacy-usage.php` para ver tu uso actual
|
||||||
|
3. Revisa los ejemplos en `tests/Integration/Infrastructure/Adapters/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Última actualización**: Fase 8 - Deprecar Legacy (2.0.0)
|
||||||
130
src/Component/Infrastructure/DI/DIContainer.php
Normal file
130
src/Component/Infrastructure/DI/DIContainer.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\DI;
|
||||||
|
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
|
||||||
|
use ROITheme\Shared\Domain\ValidationServiceInterface;
|
||||||
|
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressComponentRepository;
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
|
||||||
|
use ROITheme\Component\Infrastructure\Services\WordPressValidationService;
|
||||||
|
use ROITheme\Component\Infrastructure\Services\WordPressCacheService;
|
||||||
|
use ROITheme\Component\Infrastructure\Services\SchemaSyncService;
|
||||||
|
use ROITheme\Component\Infrastructure\Services\CleanupService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DIContainer - Contenedor de Inyección de Dependencias
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Crear y gestionar instancias de servicios
|
||||||
|
*
|
||||||
|
* PATRÓN: Service Locator + Lazy Initialization
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\DI
|
||||||
|
*/
|
||||||
|
final class DIContainer
|
||||||
|
{
|
||||||
|
private array $instances = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private \wpdb $wpdb,
|
||||||
|
private string $schemasPath
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener repositorio de componentes
|
||||||
|
*/
|
||||||
|
public function getComponentRepository(): ComponentRepositoryInterface
|
||||||
|
{
|
||||||
|
if (!isset($this->instances['componentRepository'])) {
|
||||||
|
$this->instances['componentRepository'] = new WordPressComponentRepository(
|
||||||
|
$this->wpdb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->instances['componentRepository'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener repositorio de defaults
|
||||||
|
*/
|
||||||
|
public function getDefaultsRepository(): ComponentDefaultsRepositoryInterface
|
||||||
|
{
|
||||||
|
if (!isset($this->instances['defaultsRepository'])) {
|
||||||
|
$this->instances['defaultsRepository'] = new WordPressDefaultsRepository(
|
||||||
|
$this->wpdb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->instances['defaultsRepository'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener servicio de validación
|
||||||
|
*/
|
||||||
|
public function getValidationService(): ValidationServiceInterface
|
||||||
|
{
|
||||||
|
if (!isset($this->instances['validationService'])) {
|
||||||
|
$this->instances['validationService'] = new WordPressValidationService(
|
||||||
|
$this->getDefaultsRepository()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->instances['validationService'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener servicio de cache
|
||||||
|
*/
|
||||||
|
public function getCacheService(): CacheServiceInterface
|
||||||
|
{
|
||||||
|
if (!isset($this->instances['cacheService'])) {
|
||||||
|
$this->instances['cacheService'] = new WordPressCacheService(
|
||||||
|
$this->wpdb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->instances['cacheService'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener servicio de sincronización de schemas
|
||||||
|
*/
|
||||||
|
public function getSchemaSyncService(): SchemaSyncService
|
||||||
|
{
|
||||||
|
if (!isset($this->instances['schemaSyncService'])) {
|
||||||
|
$this->instances['schemaSyncService'] = new SchemaSyncService(
|
||||||
|
$this->getDefaultsRepository(),
|
||||||
|
$this->schemasPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->instances['schemaSyncService'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener servicio de limpieza
|
||||||
|
*/
|
||||||
|
public function getCleanupService(): CleanupService
|
||||||
|
{
|
||||||
|
if (!isset($this->instances['cleanupService'])) {
|
||||||
|
$this->instances['cleanupService'] = new CleanupService(
|
||||||
|
$this->getComponentRepository(),
|
||||||
|
$this->getDefaultsRepository()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->instances['cleanupService'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resetear todas las instancias del contenedor
|
||||||
|
*
|
||||||
|
* Útil para testing para asegurar que cada test comienza con un estado limpio
|
||||||
|
*/
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
$this->instances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/Component/Infrastructure/Facades/.gitkeep
Normal file
0
src/Component/Infrastructure/Facades/.gitkeep
Normal file
115
src/Component/Infrastructure/Facades/ComponentManager.php
Normal file
115
src/Component/Infrastructure/Facades/ComponentManager.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Facades;
|
||||||
|
|
||||||
|
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentUseCase;
|
||||||
|
use ROITheme\Component\Application\UseCases\SaveComponent\SaveComponentRequest;
|
||||||
|
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentUseCase;
|
||||||
|
use ROITheme\Component\Application\UseCases\GetComponent\GetComponentRequest;
|
||||||
|
use ROITheme\Component\Infrastructure\DI\DIContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentManager - Facade para el sistema
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Punto de entrada unificado y simple
|
||||||
|
*
|
||||||
|
* PATRÓN: Facade
|
||||||
|
* - Oculta complejidad interna
|
||||||
|
* - Proporciona API simple
|
||||||
|
* - Orquesta Use Cases
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* $manager = new ComponentManager($container);
|
||||||
|
* $result = $manager->saveComponent('top_bar', $data);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Facades
|
||||||
|
*/
|
||||||
|
final class ComponentManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DIContainer $container
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar componente
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @param array $data
|
||||||
|
* @return array ['success' => bool, 'data' => mixed, 'errors' => array|null]
|
||||||
|
*/
|
||||||
|
public function saveComponent(string $componentName, array $data): array
|
||||||
|
{
|
||||||
|
$request = new SaveComponentRequest($componentName, $data);
|
||||||
|
|
||||||
|
$useCase = new SaveComponentUseCase(
|
||||||
|
$this->container->getComponentRepository(),
|
||||||
|
$this->container->getValidationService(),
|
||||||
|
$this->container->getCacheService()
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $useCase->execute($request);
|
||||||
|
|
||||||
|
return $response->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componente
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @return array ['success' => bool, 'data' => mixed, 'error' => string|null]
|
||||||
|
*/
|
||||||
|
public function getComponent(string $componentName): array
|
||||||
|
{
|
||||||
|
$request = new GetComponentRequest($componentName);
|
||||||
|
|
||||||
|
$useCase = new GetComponentUseCase(
|
||||||
|
$this->container->getComponentRepository(),
|
||||||
|
$this->container->getCacheService()
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $useCase->execute($request);
|
||||||
|
|
||||||
|
return $response->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincronizar schemas desde JSON
|
||||||
|
*
|
||||||
|
* @param string|null $componentName Si null, sincroniza todos
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function syncSchema(?string $componentName = null): array
|
||||||
|
{
|
||||||
|
$syncService = $this->container->getSchemaSyncService();
|
||||||
|
|
||||||
|
if ($componentName === null) {
|
||||||
|
return $syncService->syncAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $syncService->syncComponent($componentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpiar componentes obsoletos
|
||||||
|
*
|
||||||
|
* @return array ['removed' => array]
|
||||||
|
*/
|
||||||
|
public function cleanup(): array
|
||||||
|
{
|
||||||
|
$cleanupService = $this->container->getCleanupService();
|
||||||
|
return $cleanupService->removeObsolete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidar todo el cache
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function invalidateCache(): bool
|
||||||
|
{
|
||||||
|
return $this->container->getCacheService()->invalidateAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/Component/Infrastructure/Logging/DeprecationLogger.php
Normal file
350
src/Component/Infrastructure/Logging/DeprecationLogger.php
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Logging;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeprecationLogger - Sistema de logging para código deprecated
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Registrar uso de código legacy para análisis y migración
|
||||||
|
*
|
||||||
|
* FEATURES:
|
||||||
|
* - Log en archivo de texto
|
||||||
|
* - Almacenamiento en base de datos para reportes
|
||||||
|
* - Niveles de severidad
|
||||||
|
* - Deduplicación de entradas
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Logging
|
||||||
|
*/
|
||||||
|
final class DeprecationLogger
|
||||||
|
{
|
||||||
|
private static ?self $instance = null;
|
||||||
|
private string $logFile;
|
||||||
|
private \wpdb $wpdb;
|
||||||
|
private string $tableName;
|
||||||
|
private bool $enabled = true;
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$this->wpdb = $wpdb;
|
||||||
|
$this->tableName = $wpdb->prefix . 'roi_theme_deprecation_log';
|
||||||
|
|
||||||
|
$uploadDir = wp_upload_dir();
|
||||||
|
$logDir = $uploadDir['basedir'] . '/roi-theme-logs';
|
||||||
|
|
||||||
|
if (!file_exists($logDir)) {
|
||||||
|
wp_mkdir_p($logDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logFile = $logDir . '/deprecation-' . date('Y-m-d') . '.log';
|
||||||
|
|
||||||
|
// Asegurar que la tabla existe
|
||||||
|
$this->ensureTableExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static function getInstance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log deprecation use
|
||||||
|
*
|
||||||
|
* @param string $class Class name
|
||||||
|
* @param string $method Method name
|
||||||
|
* @param array $args Arguments passed
|
||||||
|
* @param string $replacement Suggested replacement
|
||||||
|
* @param string $version Version deprecated in
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function log(
|
||||||
|
string $class,
|
||||||
|
string $method,
|
||||||
|
array $args = [],
|
||||||
|
string $replacement = '',
|
||||||
|
string $version = '2.0.0'
|
||||||
|
): void {
|
||||||
|
if (!$this->enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logEntry = [
|
||||||
|
'timestamp' => current_time('mysql'),
|
||||||
|
'class' => $class,
|
||||||
|
'method' => $method,
|
||||||
|
'args' => json_encode($args),
|
||||||
|
'replacement' => $replacement,
|
||||||
|
'version' => $version,
|
||||||
|
'backtrace' => $this->getSimplifiedBacktrace(),
|
||||||
|
'url' => $this->getCurrentUrl(),
|
||||||
|
'user_id' => get_current_user_id()
|
||||||
|
];
|
||||||
|
|
||||||
|
// Log to file
|
||||||
|
$this->logToFile($logEntry);
|
||||||
|
|
||||||
|
// Log to database (with deduplication)
|
||||||
|
$this->logToDatabase($logEntry);
|
||||||
|
|
||||||
|
// Trigger action for external monitoring
|
||||||
|
do_action('roi_theme_deprecation_logged', $logEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deprecation statistics
|
||||||
|
*
|
||||||
|
* @param int $days Number of days to analyze
|
||||||
|
* @return array Statistics
|
||||||
|
*/
|
||||||
|
public function getStatistics(int $days = 7): array
|
||||||
|
{
|
||||||
|
$since = date('Y-m-d H:i:s', strtotime("-{$days} days"));
|
||||||
|
|
||||||
|
$sql = $this->wpdb->prepare(
|
||||||
|
"SELECT
|
||||||
|
class,
|
||||||
|
method,
|
||||||
|
COUNT(*) as usage_count,
|
||||||
|
MAX(timestamp) as last_used,
|
||||||
|
replacement
|
||||||
|
FROM {$this->tableName}
|
||||||
|
WHERE timestamp >= %s
|
||||||
|
GROUP BY class, method, replacement
|
||||||
|
ORDER BY usage_count DESC",
|
||||||
|
$since
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->wpdb->get_results($sql, ARRAY_A) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get most used deprecated methods
|
||||||
|
*
|
||||||
|
* @param int $limit Number of results
|
||||||
|
* @return array Top deprecated methods
|
||||||
|
*/
|
||||||
|
public function getTopDeprecated(int $limit = 10): array
|
||||||
|
{
|
||||||
|
$sql = $this->wpdb->prepare(
|
||||||
|
"SELECT
|
||||||
|
class,
|
||||||
|
method,
|
||||||
|
COUNT(*) as usage_count,
|
||||||
|
replacement
|
||||||
|
FROM {$this->tableName}
|
||||||
|
GROUP BY class, method, replacement
|
||||||
|
ORDER BY usage_count DESC
|
||||||
|
LIMIT %d",
|
||||||
|
$limit
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->wpdb->get_results($sql, ARRAY_A) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear old logs
|
||||||
|
*
|
||||||
|
* @param int $days Keep logs newer than this many days
|
||||||
|
* @return int Number of deleted entries
|
||||||
|
*/
|
||||||
|
public function clearOldLogs(int $days = 30): int
|
||||||
|
{
|
||||||
|
$since = date('Y-m-d H:i:s', strtotime("-{$days} days"));
|
||||||
|
|
||||||
|
$deleted = $this->wpdb->query(
|
||||||
|
$this->wpdb->prepare(
|
||||||
|
"DELETE FROM {$this->tableName} WHERE timestamp < %s",
|
||||||
|
$since
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable logging
|
||||||
|
*
|
||||||
|
* @param bool $enabled
|
||||||
|
*/
|
||||||
|
public function setEnabled(bool $enabled): void
|
||||||
|
{
|
||||||
|
$this->enabled = $enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log to file
|
||||||
|
*
|
||||||
|
* @param array $entry Log entry
|
||||||
|
*/
|
||||||
|
private function logToFile(array $entry): void
|
||||||
|
{
|
||||||
|
$message = sprintf(
|
||||||
|
"[%s] %s::%s() deprecated in v%s - Use %s instead\n",
|
||||||
|
$entry['timestamp'],
|
||||||
|
$entry['class'],
|
||||||
|
$entry['method'],
|
||||||
|
$entry['version'],
|
||||||
|
$entry['replacement'] ?: 'see documentation'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($entry['backtrace'])) {
|
||||||
|
$message .= " Called from: {$entry['backtrace']}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($entry['url'])) {
|
||||||
|
$message .= " URL: {$entry['url']}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= " ---\n";
|
||||||
|
|
||||||
|
error_log($message, 3, $this->logFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log to database with deduplication
|
||||||
|
*
|
||||||
|
* @param array $entry Log entry
|
||||||
|
*/
|
||||||
|
private function logToDatabase(array $entry): void
|
||||||
|
{
|
||||||
|
// Check if same call was logged in last hour (deduplication)
|
||||||
|
$hash = md5($entry['class'] . $entry['method'] . $entry['url']);
|
||||||
|
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
|
||||||
|
|
||||||
|
$exists = $this->wpdb->get_var(
|
||||||
|
$this->wpdb->prepare(
|
||||||
|
"SELECT id FROM {$this->tableName}
|
||||||
|
WHERE call_hash = %s
|
||||||
|
AND timestamp > %s
|
||||||
|
LIMIT 1",
|
||||||
|
$hash,
|
||||||
|
$oneHourAgo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
// Update count instead of creating new entry
|
||||||
|
$this->wpdb->query(
|
||||||
|
$this->wpdb->prepare(
|
||||||
|
"UPDATE {$this->tableName}
|
||||||
|
SET usage_count = usage_count + 1,
|
||||||
|
timestamp = %s
|
||||||
|
WHERE id = %d",
|
||||||
|
$entry['timestamp'],
|
||||||
|
$exists
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new entry
|
||||||
|
$this->wpdb->insert(
|
||||||
|
$this->tableName,
|
||||||
|
[
|
||||||
|
'timestamp' => $entry['timestamp'],
|
||||||
|
'class' => $entry['class'],
|
||||||
|
'method' => $entry['method'],
|
||||||
|
'args' => $entry['args'],
|
||||||
|
'replacement' => $entry['replacement'],
|
||||||
|
'version' => $entry['version'],
|
||||||
|
'backtrace' => $entry['backtrace'],
|
||||||
|
'url' => $entry['url'],
|
||||||
|
'user_id' => $entry['user_id'],
|
||||||
|
'call_hash' => $hash,
|
||||||
|
'usage_count' => 1
|
||||||
|
],
|
||||||
|
['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get simplified backtrace
|
||||||
|
*
|
||||||
|
* @return string Simplified backtrace
|
||||||
|
*/
|
||||||
|
private function getSimplifiedBacktrace(): string
|
||||||
|
{
|
||||||
|
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
|
||||||
|
|
||||||
|
// Skip first 3 frames (this method, log method, deprecated function)
|
||||||
|
$relevant = array_slice($trace, 3, 2);
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
foreach ($relevant as $frame) {
|
||||||
|
if (isset($frame['file']) && isset($frame['line'])) {
|
||||||
|
$file = basename($frame['file']);
|
||||||
|
$parts[] = "{$file}:{$frame['line']}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' -> ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current URL
|
||||||
|
*
|
||||||
|
* @return string Current URL
|
||||||
|
*/
|
||||||
|
private function getCurrentUrl(): string
|
||||||
|
{
|
||||||
|
if (!isset($_SERVER['REQUEST_URI'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = is_ssl() ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure database table exists
|
||||||
|
*/
|
||||||
|
private function ensureTableExists(): void
|
||||||
|
{
|
||||||
|
$charset_collate = $this->wpdb->get_charset_collate();
|
||||||
|
|
||||||
|
$sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} (
|
||||||
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
timestamp datetime NOT NULL,
|
||||||
|
class varchar(255) NOT NULL,
|
||||||
|
method varchar(255) NOT NULL,
|
||||||
|
args text,
|
||||||
|
replacement varchar(255) DEFAULT '',
|
||||||
|
version varchar(20) DEFAULT '2.0.0',
|
||||||
|
backtrace text,
|
||||||
|
url varchar(500) DEFAULT '',
|
||||||
|
user_id bigint(20) unsigned DEFAULT 0,
|
||||||
|
call_hash varchar(32) NOT NULL,
|
||||||
|
usage_count int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY class_method (class, method),
|
||||||
|
KEY timestamp (timestamp),
|
||||||
|
KEY call_hash (call_hash)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||||
|
dbDelta($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent cloning
|
||||||
|
*/
|
||||||
|
private function __clone() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent unserialization
|
||||||
|
*/
|
||||||
|
public function __wakeup()
|
||||||
|
{
|
||||||
|
throw new \Exception('Cannot unserialize singleton');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Persistence\WordPress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DatabaseMigrator - Migrador de Base de Datos Legacy a Clean Architecture
|
||||||
|
*
|
||||||
|
* Responsabilidad: Transformar estructura de BD legacy a nueva estructura
|
||||||
|
*
|
||||||
|
* ESTRATEGIA DE MIGRACIÓN:
|
||||||
|
* 1. Crear tablas nuevas con sufijo _v2
|
||||||
|
* 2. Migrar datos de legacy a nuevas tablas
|
||||||
|
* 3. Validar integridad de datos migrados
|
||||||
|
* 4. Renombrar tablas (legacy → _backup, v2 → producción)
|
||||||
|
* 5. Mantener backup por 30 días para rollback
|
||||||
|
*
|
||||||
|
* TRANSFORMACIONES:
|
||||||
|
* - Agregar columna config_group (inferida desde config_key)
|
||||||
|
* - Renombrar version → schema_version
|
||||||
|
* - Optimizar índices para consultas jerárquicas
|
||||||
|
*
|
||||||
|
* SEGURIDAD:
|
||||||
|
* - Rollback automático si falla validación
|
||||||
|
* - Backup de tablas legacy preservado
|
||||||
|
* - Logging detallado de cada operación
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* global $wpdb;
|
||||||
|
* $migrator = new DatabaseMigrator($wpdb);
|
||||||
|
* $result = $migrator->migrate();
|
||||||
|
*
|
||||||
|
* if ($result['success']) {
|
||||||
|
* echo "Migración exitosa: " . $result['stats']['components']['migrated'] . " registros";
|
||||||
|
* } else {
|
||||||
|
* echo "Error: " . $result['message'];
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
final class DatabaseMigrator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var \wpdb Instancia de WordPress Database
|
||||||
|
*/
|
||||||
|
private \wpdb $wpdb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Charset y Collation de la BD
|
||||||
|
*/
|
||||||
|
private string $charset_collate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Log de operaciones ejecutadas
|
||||||
|
*/
|
||||||
|
private array $log = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param \wpdb $wpdb Instancia de WordPress Database
|
||||||
|
*/
|
||||||
|
public function __construct(\wpdb $wpdb)
|
||||||
|
{
|
||||||
|
$this->wpdb = $wpdb;
|
||||||
|
$this->charset_collate = $wpdb->get_charset_collate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecutar migración completa de BD
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message: string, stats: array, log: array}
|
||||||
|
*/
|
||||||
|
public function migrate(): array
|
||||||
|
{
|
||||||
|
$this->log('🚀 Iniciando migración de base de datos');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Verificar que tablas legacy existen
|
||||||
|
if (!$this->legacyTablesExist()) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Tablas legacy no encontradas. No hay nada que migrar.',
|
||||||
|
'stats' => [],
|
||||||
|
'log' => $this->log
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log('✓ Tablas legacy encontradas');
|
||||||
|
|
||||||
|
// 2. Crear tablas v2 con nueva estructura
|
||||||
|
$this->createV2Tables();
|
||||||
|
$this->log('✓ Tablas v2 creadas con nueva estructura');
|
||||||
|
|
||||||
|
// 3. Migrar datos de components
|
||||||
|
$componentsStats = $this->migrateComponentsData();
|
||||||
|
$this->log("✓ Components migrados: {$componentsStats['migrated']} registros");
|
||||||
|
|
||||||
|
// 4. Migrar datos de defaults
|
||||||
|
$defaultsStats = $this->migrateDefaultsData();
|
||||||
|
$this->log("✓ Defaults migrados: {$defaultsStats['migrated']} registros");
|
||||||
|
|
||||||
|
// 5. Validar integridad de datos
|
||||||
|
$validation = $this->validateMigration();
|
||||||
|
|
||||||
|
if (!$validation['success']) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Validación de migración falló: ' . $validation['message']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log('✓ Validación de integridad exitosa');
|
||||||
|
|
||||||
|
// 6. Hacer swap de tablas
|
||||||
|
$this->swapTables();
|
||||||
|
$this->log('✓ Swap de tablas completado (legacy → _backup, v2 → producción)');
|
||||||
|
|
||||||
|
// 7. Guardar metadata de migración
|
||||||
|
update_option('roi_theme_migration_date', current_time('mysql'));
|
||||||
|
update_option('roi_theme_migration_stats', [
|
||||||
|
'components' => $componentsStats,
|
||||||
|
'defaults' => $defaultsStats
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->log('✅ Migración completada exitosamente');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Migración completada exitosamente',
|
||||||
|
'stats' => [
|
||||||
|
'components' => $componentsStats,
|
||||||
|
'defaults' => $defaultsStats,
|
||||||
|
'validation' => $validation
|
||||||
|
],
|
||||||
|
'log' => $this->log
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log('❌ Error durante migración: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Rollback en caso de error
|
||||||
|
$this->rollback();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Error durante migración: ' . $e->getMessage(),
|
||||||
|
'stats' => [],
|
||||||
|
'log' => $this->log
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si las tablas legacy existen
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function legacyTablesExist(): bool
|
||||||
|
{
|
||||||
|
$legacy_components = $this->wpdb->prefix . 'roi_theme_components';
|
||||||
|
$legacy_defaults = $this->wpdb->prefix . 'roi_theme_components_defaults';
|
||||||
|
|
||||||
|
$components_exist = $this->wpdb->get_var(
|
||||||
|
"SHOW TABLES LIKE '{$legacy_components}'"
|
||||||
|
) === $legacy_components;
|
||||||
|
|
||||||
|
$defaults_exist = $this->wpdb->get_var(
|
||||||
|
"SHOW TABLES LIKE '{$legacy_defaults}'"
|
||||||
|
) === $legacy_defaults;
|
||||||
|
|
||||||
|
return $components_exist && $defaults_exist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear tablas v2 con nueva estructura
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function createV2Tables(): void
|
||||||
|
{
|
||||||
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||||
|
|
||||||
|
$components_v2 = $this->wpdb->prefix . 'roi_theme_components_v2';
|
||||||
|
$defaults_v2 = $this->wpdb->prefix . 'roi_theme_components_defaults_v2';
|
||||||
|
|
||||||
|
// Estructura mejorada con config_group
|
||||||
|
$table_structure = "
|
||||||
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
component_name VARCHAR(50) NOT NULL,
|
||||||
|
config_group VARCHAR(50) NOT NULL,
|
||||||
|
config_key VARCHAR(100) NOT NULL,
|
||||||
|
config_value TEXT NOT NULL,
|
||||||
|
data_type VARCHAR(20) NOT NULL DEFAULT 'string',
|
||||||
|
schema_version VARCHAR(10) NOT NULL DEFAULT '1.0.0',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY component_config (component_name, config_group, config_key),
|
||||||
|
INDEX idx_component (component_name),
|
||||||
|
INDEX idx_group (component_name, config_group),
|
||||||
|
INDEX idx_schema_version (component_name, schema_version),
|
||||||
|
INDEX idx_config_key (config_key)
|
||||||
|
";
|
||||||
|
|
||||||
|
$sql_components = "CREATE TABLE {$components_v2} ({$table_structure}) {$this->charset_collate};";
|
||||||
|
$sql_defaults = "CREATE TABLE {$defaults_v2} ({$table_structure}) {$this->charset_collate};";
|
||||||
|
|
||||||
|
dbDelta($sql_components);
|
||||||
|
dbDelta($sql_defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrar datos de components legacy a v2
|
||||||
|
*
|
||||||
|
* @return array{migrated: int, skipped: int, errors: array}
|
||||||
|
*/
|
||||||
|
private function migrateComponentsData(): array
|
||||||
|
{
|
||||||
|
$legacy_table = $this->wpdb->prefix . 'roi_theme_components';
|
||||||
|
$v2_table = $this->wpdb->prefix . 'roi_theme_components_v2';
|
||||||
|
|
||||||
|
return $this->migrateTableData($legacy_table, $v2_table);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrar datos de defaults legacy a v2
|
||||||
|
*
|
||||||
|
* @return array{migrated: int, skipped: int, errors: array}
|
||||||
|
*/
|
||||||
|
private function migrateDefaultsData(): array
|
||||||
|
{
|
||||||
|
$legacy_table = $this->wpdb->prefix . 'roi_theme_components_defaults';
|
||||||
|
$v2_table = $this->wpdb->prefix . 'roi_theme_components_defaults_v2';
|
||||||
|
|
||||||
|
return $this->migrateTableData($legacy_table, $v2_table);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrar datos de una tabla legacy a v2
|
||||||
|
*
|
||||||
|
* @param string $legacy_table Nombre de tabla legacy
|
||||||
|
* @param string $v2_table Nombre de tabla v2
|
||||||
|
* @return array{migrated: int, skipped: int, errors: array}
|
||||||
|
*/
|
||||||
|
private function migrateTableData(string $legacy_table, string $v2_table): array
|
||||||
|
{
|
||||||
|
// Obtener todos los registros legacy
|
||||||
|
$legacy_rows = $this->wpdb->get_results(
|
||||||
|
"SELECT * FROM {$legacy_table}",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empty($legacy_rows)) {
|
||||||
|
return ['migrated' => 0, 'skipped' => 0, 'errors' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($legacy_rows as $row) {
|
||||||
|
try {
|
||||||
|
// Inferir config_group desde config_key
|
||||||
|
$group = $this->inferGroupFromKey($row['config_key']);
|
||||||
|
|
||||||
|
// Preparar datos para inserción
|
||||||
|
$data = [
|
||||||
|
'component_name' => $row['component_name'],
|
||||||
|
'config_group' => $group,
|
||||||
|
'config_key' => $row['config_key'],
|
||||||
|
'config_value' => $row['config_value'],
|
||||||
|
'data_type' => $row['data_type'],
|
||||||
|
'schema_version' => $row['version'] ?? '1.0.0',
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'updated_at' => $row['updated_at']
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insertar en tabla v2
|
||||||
|
$result = $this->wpdb->insert(
|
||||||
|
$v2_table,
|
||||||
|
$data,
|
||||||
|
['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result !== false) {
|
||||||
|
$migrated++;
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
$errors[] = "Error migrando: {$row['component_name']}.{$row['config_key']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$skipped++;
|
||||||
|
$errors[] = "Excepción migrando {$row['component_name']}.{$row['config_key']}: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'migrated' => $migrated,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'errors' => $errors
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inferir grupo de configuración desde la clave
|
||||||
|
*
|
||||||
|
* HEURÍSTICA:
|
||||||
|
* - enabled, visible_* → visibility
|
||||||
|
* - message_*, cta_*, title_* → content
|
||||||
|
* - *_color, *_height, *_width, *_size → styles
|
||||||
|
* - Resto → general
|
||||||
|
*
|
||||||
|
* @param string $key Clave de configuración
|
||||||
|
* @return string Grupo inferido
|
||||||
|
*/
|
||||||
|
private function inferGroupFromKey(string $key): string
|
||||||
|
{
|
||||||
|
// Visibility
|
||||||
|
if (in_array($key, ['enabled', 'visible_desktop', 'visible_mobile', 'visible_tablet'], true)) {
|
||||||
|
return 'visibility';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (str_starts_with($key, 'message_') ||
|
||||||
|
str_starts_with($key, 'cta_') ||
|
||||||
|
str_starts_with($key, 'title_')) {
|
||||||
|
return 'content';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
if (str_ends_with($key, '_color') ||
|
||||||
|
str_ends_with($key, '_height') ||
|
||||||
|
str_ends_with($key, '_width') ||
|
||||||
|
str_ends_with($key, '_size') ||
|
||||||
|
str_ends_with($key, '_font')) {
|
||||||
|
return 'styles';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar integridad de la migración
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message: string, details: array}
|
||||||
|
*/
|
||||||
|
private function validateMigration(): array
|
||||||
|
{
|
||||||
|
$legacy_components = $this->wpdb->prefix . 'roi_theme_components';
|
||||||
|
$legacy_defaults = $this->wpdb->prefix . 'roi_theme_components_defaults';
|
||||||
|
$v2_components = $this->wpdb->prefix . 'roi_theme_components_v2';
|
||||||
|
$v2_defaults = $this->wpdb->prefix . 'roi_theme_components_defaults_v2';
|
||||||
|
|
||||||
|
$details = [];
|
||||||
|
|
||||||
|
// 1. Validar cantidad de registros components
|
||||||
|
$legacy_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$legacy_components}");
|
||||||
|
$v2_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$v2_components}");
|
||||||
|
|
||||||
|
$details['components_count'] = [
|
||||||
|
'legacy' => (int) $legacy_count,
|
||||||
|
'v2' => (int) $v2_count,
|
||||||
|
'match' => $legacy_count == $v2_count
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($legacy_count != $v2_count) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Mismatch en cantidad de registros components: legacy={$legacy_count}, v2={$v2_count}",
|
||||||
|
'details' => $details
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validar cantidad de registros defaults
|
||||||
|
$legacy_defaults_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$legacy_defaults}");
|
||||||
|
$v2_defaults_count = $this->wpdb->get_var("SELECT COUNT(*) FROM {$v2_defaults}");
|
||||||
|
|
||||||
|
$details['defaults_count'] = [
|
||||||
|
'legacy' => (int) $legacy_defaults_count,
|
||||||
|
'v2' => (int) $v2_defaults_count,
|
||||||
|
'match' => $legacy_defaults_count == $v2_defaults_count
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($legacy_defaults_count != $v2_defaults_count) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Mismatch en cantidad de registros defaults: legacy={$legacy_defaults_count}, v2={$v2_defaults_count}",
|
||||||
|
'details' => $details
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar componentes únicos
|
||||||
|
$legacy_component_names = $this->wpdb->get_col(
|
||||||
|
"SELECT DISTINCT component_name FROM {$legacy_components}"
|
||||||
|
);
|
||||||
|
$v2_component_names = $this->wpdb->get_col(
|
||||||
|
"SELECT DISTINCT component_name FROM {$v2_components}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$missing_components = array_diff($legacy_component_names, $v2_component_names);
|
||||||
|
|
||||||
|
if (count($missing_components) > 0) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Faltan componentes en v2: " . implode(', ', $missing_components),
|
||||||
|
'details' => $details
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verificar grupos válidos
|
||||||
|
$invalid_groups = $this->wpdb->get_col(
|
||||||
|
"SELECT DISTINCT config_group FROM {$v2_components}
|
||||||
|
WHERE config_group NOT IN ('visibility', 'content', 'styles', 'general')"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count($invalid_groups) > 0) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Grupos inválidos encontrados: " . implode(', ', $invalid_groups),
|
||||||
|
'details' => $details
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "Validación exitosa: {$legacy_count} components + {$legacy_defaults_count} defaults migrados correctamente",
|
||||||
|
'details' => $details
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hacer swap de tablas (legacy → backup, v2 → producción)
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function swapTables(): void
|
||||||
|
{
|
||||||
|
// Swap components
|
||||||
|
$this->wpdb->query(
|
||||||
|
"RENAME TABLE
|
||||||
|
{$this->wpdb->prefix}roi_theme_components TO {$this->wpdb->prefix}roi_theme_components_backup,
|
||||||
|
{$this->wpdb->prefix}roi_theme_components_v2 TO {$this->wpdb->prefix}roi_theme_components"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Swap defaults
|
||||||
|
$this->wpdb->query(
|
||||||
|
"RENAME TABLE
|
||||||
|
{$this->wpdb->prefix}roi_theme_components_defaults TO {$this->wpdb->prefix}roi_theme_components_defaults_backup,
|
||||||
|
{$this->wpdb->prefix}roi_theme_components_defaults_v2 TO {$this->wpdb->prefix}roi_theme_components_defaults"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guardar timestamp de backup
|
||||||
|
update_option('roi_theme_migration_backup_date', current_time('mysql'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback: Eliminar tablas v2 en caso de error
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function rollback(): void
|
||||||
|
{
|
||||||
|
$this->log('⚠️ Ejecutando rollback...');
|
||||||
|
|
||||||
|
// Eliminar tablas v2 si existen
|
||||||
|
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_v2");
|
||||||
|
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_defaults_v2");
|
||||||
|
|
||||||
|
$this->log('✓ Rollback completado: tablas v2 eliminadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpiar tablas de backup (ejecutar después de validar migración)
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function cleanupBackup(): void
|
||||||
|
{
|
||||||
|
$this->log('🗑️ Eliminando tablas de backup...');
|
||||||
|
|
||||||
|
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_backup");
|
||||||
|
$this->wpdb->query("DROP TABLE IF EXISTS {$this->wpdb->prefix}roi_theme_components_defaults_backup");
|
||||||
|
|
||||||
|
delete_option('roi_theme_migration_backup_date');
|
||||||
|
|
||||||
|
$this->log('✓ Tablas de backup eliminadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agregar entrada al log
|
||||||
|
*
|
||||||
|
* @param string $message Mensaje a registrar
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function log(string $message): void
|
||||||
|
{
|
||||||
|
$timestamp = current_time('Y-m-d H:i:s');
|
||||||
|
$entry = "[{$timestamp}] {$message}";
|
||||||
|
|
||||||
|
$this->log[] = $entry;
|
||||||
|
error_log('ROI Theme Migration: ' . $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener log completo de operaciones
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getLog(): array
|
||||||
|
{
|
||||||
|
return $this->log;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Persistence\WordPress;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentVisibility;
|
||||||
|
use ROITheme\Shared\Domain\ComponentRepositoryInterface;
|
||||||
|
use ROITheme\Component\Domain\Exceptions\ComponentNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPressComponentRepository - Implementación con WordPress/MySQL
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Persistir y recuperar componentes desde wp_roi_theme_components
|
||||||
|
*
|
||||||
|
* DEPENDENCIAS EXTERNAS:
|
||||||
|
* - wpdb (WordPress database abstraction)
|
||||||
|
* - Tabla wp_roi_theme_components (creada en Fase 2)
|
||||||
|
*
|
||||||
|
* CONVERSIÓN:
|
||||||
|
* - Entity → Array → MySQL (al guardar)
|
||||||
|
* - MySQL → Array → Entity (al recuperar)
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Persistence\WordPress
|
||||||
|
*/
|
||||||
|
final class WordPressComponentRepository implements ComponentRepositoryInterface
|
||||||
|
{
|
||||||
|
private string $tableName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \wpdb $wpdb WordPress database object
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private \wpdb $wpdb
|
||||||
|
) {
|
||||||
|
$this->tableName = $this->wpdb->prefix . 'roi_theme_components';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar componente
|
||||||
|
*
|
||||||
|
* INSERT si no existe, UPDATE si existe
|
||||||
|
*
|
||||||
|
* @param Component $component
|
||||||
|
* @return Component Componente guardado (con timestamps actualizados)
|
||||||
|
*/
|
||||||
|
public function save(Component $component): Component
|
||||||
|
{
|
||||||
|
$componentName = $component->name()->value();
|
||||||
|
|
||||||
|
// Verificar si ya existe
|
||||||
|
$existing = $this->findByName($componentName);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'component_name' => $componentName,
|
||||||
|
'configuration' => json_encode($component->configuration()->toArray()),
|
||||||
|
'visibility' => json_encode($component->visibility()->toArray()),
|
||||||
|
'is_enabled' => $component->isEnabled() ? 1 : 0,
|
||||||
|
'schema_version' => $component->schemaVersion(),
|
||||||
|
'updated_at' => current_time('mysql')
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing === null) {
|
||||||
|
// INSERT
|
||||||
|
$data['created_at'] = current_time('mysql');
|
||||||
|
|
||||||
|
$this->wpdb->insert(
|
||||||
|
$this->tableName,
|
||||||
|
$data,
|
||||||
|
['%s', '%s', '%s', '%d', '%s', '%s', '%s']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// UPDATE
|
||||||
|
$this->wpdb->update(
|
||||||
|
$this->tableName,
|
||||||
|
$data,
|
||||||
|
['component_name' => $componentName],
|
||||||
|
['%s', '%s', '%s', '%d', '%s', '%s'],
|
||||||
|
['%s']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the saved component by fetching it from the database
|
||||||
|
return $this->getByName($component->name());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar componente por nombre
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @return Component|null Null si no existe
|
||||||
|
*/
|
||||||
|
public function findByName(ComponentName $name): ?Component
|
||||||
|
{
|
||||||
|
$sql = $this->wpdb->prepare(
|
||||||
|
"SELECT * FROM {$this->tableName} WHERE component_name = %s LIMIT 1",
|
||||||
|
$name->value()
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->wpdb->get_row($sql, ARRAY_A);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rowToEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componente por nombre (lanza excepción si no existe)
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return Component
|
||||||
|
* @throws ComponentNotFoundException
|
||||||
|
*/
|
||||||
|
public function getByName(ComponentName $name): Component
|
||||||
|
{
|
||||||
|
$component = $this->findByName($name);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
throw ComponentNotFoundException::withName($name->value());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $component;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los componentes
|
||||||
|
*
|
||||||
|
* @return Component[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT * FROM {$this->tableName} ORDER BY component_name ASC";
|
||||||
|
$rows = $this->wpdb->get_results($sql, ARRAY_A);
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn($row) => $this->rowToEntity($row),
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @return bool True si eliminó exitosamente
|
||||||
|
*/
|
||||||
|
public function delete(ComponentName $name): bool
|
||||||
|
{
|
||||||
|
$result = $this->wpdb->delete(
|
||||||
|
$this->tableName,
|
||||||
|
['component_name' => $name->value()],
|
||||||
|
['%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componentes habilitados
|
||||||
|
*
|
||||||
|
* @return Component[]
|
||||||
|
*/
|
||||||
|
public function findEnabled(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT * FROM {$this->tableName} WHERE is_enabled = 1 ORDER BY component_name ASC";
|
||||||
|
$rows = $this->wpdb->get_results($sql, ARRAY_A);
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn($row) => $this->rowToEntity($row),
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si existe un componente con el nombre dado
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(ComponentName $name): bool
|
||||||
|
{
|
||||||
|
return $this->findByName($name) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener cantidad total de componentes
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
$sql = "SELECT COUNT(*) FROM {$this->tableName}";
|
||||||
|
return (int) $this->wpdb->get_var($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componentes por grupo de configuración
|
||||||
|
*
|
||||||
|
* @param string $group Grupo de configuración (visibility, content, styles, general)
|
||||||
|
* @return Component[]
|
||||||
|
*/
|
||||||
|
public function findByConfigGroup(string $group): array
|
||||||
|
{
|
||||||
|
// For now, return all components as we don't have a specific column for groups
|
||||||
|
// This would require additional schema design
|
||||||
|
return $this->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir fila de BD a Entity
|
||||||
|
*
|
||||||
|
* @param array $row Fila de la base de datos
|
||||||
|
* @return Component
|
||||||
|
*/
|
||||||
|
private function rowToEntity(array $row): Component
|
||||||
|
{
|
||||||
|
// Decodificar JSON
|
||||||
|
$configuration = json_decode($row['configuration'], true) ?? [];
|
||||||
|
$visibility = json_decode($row['visibility'], true) ?? [];
|
||||||
|
|
||||||
|
// Crear Value Objects
|
||||||
|
$name = new ComponentName($row['component_name']);
|
||||||
|
$config = ComponentConfiguration::fromArray($configuration);
|
||||||
|
$vis = ComponentVisibility::fromArray($visibility);
|
||||||
|
|
||||||
|
// Crear Entity
|
||||||
|
return new Component(
|
||||||
|
$name,
|
||||||
|
$config,
|
||||||
|
$vis,
|
||||||
|
(bool) $row['is_enabled'],
|
||||||
|
$row['schema_version']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Persistence\WordPress;
|
||||||
|
|
||||||
|
use ROITheme\Shared\Domain\ComponentDefaultsRepositoryInterface;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPressDefaultsRepository - Defaults/Schemas desde MySQL
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Gestionar schemas de componentes (estructura, validaciones, defaults)
|
||||||
|
*
|
||||||
|
* TABLA: wp_roi_theme_defaults
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Persistence\WordPress
|
||||||
|
*/
|
||||||
|
final class WordPressDefaultsRepository implements ComponentDefaultsRepositoryInterface
|
||||||
|
{
|
||||||
|
private string $tableName;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private \wpdb $wpdb
|
||||||
|
) {
|
||||||
|
$this->tableName = $this->wpdb->prefix . 'roi_theme_defaults';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener configuración por defecto de un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @return ComponentConfiguration Configuración por defecto
|
||||||
|
*/
|
||||||
|
public function getByName(ComponentName $name): ComponentConfiguration
|
||||||
|
{
|
||||||
|
$sql = $this->wpdb->prepare(
|
||||||
|
"SELECT default_schema FROM {$this->tableName} WHERE component_name = %s LIMIT 1",
|
||||||
|
$name->value()
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->wpdb->get_var($sql);
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
// Return empty configuration if no defaults found
|
||||||
|
return ComponentConfiguration::fromArray([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = json_decode($result, true) ?? [];
|
||||||
|
return ComponentConfiguration::fromArray($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar configuración por defecto para un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @param ComponentConfiguration $configuration Configuración por defecto
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function save(ComponentName $name, ComponentConfiguration $configuration): void
|
||||||
|
{
|
||||||
|
$existing = $this->exists($name);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'component_name' => $name->value(),
|
||||||
|
'default_schema' => json_encode($configuration->all()),
|
||||||
|
'updated_at' => current_time('mysql')
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
// INSERT
|
||||||
|
$data['created_at'] = current_time('mysql');
|
||||||
|
|
||||||
|
$this->wpdb->insert(
|
||||||
|
$this->tableName,
|
||||||
|
$data,
|
||||||
|
['%s', '%s', '%s', '%s']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// UPDATE
|
||||||
|
$this->wpdb->update(
|
||||||
|
$this->tableName,
|
||||||
|
$data,
|
||||||
|
['component_name' => $name->value()],
|
||||||
|
['%s', '%s', '%s'],
|
||||||
|
['%s']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si existen defaults para un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(ComponentName $name): bool
|
||||||
|
{
|
||||||
|
$sql = $this->wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$this->tableName} WHERE component_name = %s",
|
||||||
|
$name->value()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $this->wpdb->get_var($sql) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los defaults
|
||||||
|
*
|
||||||
|
* @return array<string, ComponentConfiguration> Array asociativo nombre => configuración
|
||||||
|
*/
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT component_name, default_schema FROM {$this->tableName}";
|
||||||
|
$rows = $this->wpdb->get_results($sql, ARRAY_A);
|
||||||
|
|
||||||
|
$defaults = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$schema = json_decode($row['default_schema'], true) ?? [];
|
||||||
|
$defaults[$row['component_name']] = ComponentConfiguration::fromArray($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar defaults de un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return bool True si se eliminó, false si no existía
|
||||||
|
*/
|
||||||
|
public function delete(ComponentName $name): bool
|
||||||
|
{
|
||||||
|
$result = $this->wpdb->delete(
|
||||||
|
$this->tableName,
|
||||||
|
['component_name' => $name->value()],
|
||||||
|
['%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result !== false && $result > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener schema/defaults de un componente (método legacy)
|
||||||
|
*
|
||||||
|
* @deprecated Use getByName() instead
|
||||||
|
* @param string $componentName
|
||||||
|
* @return array|null Schema del componente o null si no existe
|
||||||
|
*/
|
||||||
|
public function find(string $componentName): ?array
|
||||||
|
{
|
||||||
|
$name = new ComponentName($componentName);
|
||||||
|
if (!$this->exists($name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->getByName($name);
|
||||||
|
return $config->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar schema/defaults de un componente (método legacy)
|
||||||
|
*
|
||||||
|
* @deprecated Use save() instead
|
||||||
|
* @param string $componentName
|
||||||
|
* @param array $schema
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function saveDefaults(string $componentName, array $schema): bool
|
||||||
|
{
|
||||||
|
$name = new ComponentName($componentName);
|
||||||
|
$config = ComponentConfiguration::fromArray($schema);
|
||||||
|
$this->save($name, $config);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar schema existente (método legacy)
|
||||||
|
*
|
||||||
|
* @deprecated Use save() instead
|
||||||
|
* @param string $componentName
|
||||||
|
* @param array $schema
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function updateDefaults(string $componentName, array $schema): bool
|
||||||
|
{
|
||||||
|
return $this->saveDefaults($componentName, $schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar defaults de un componente (método legacy)
|
||||||
|
*
|
||||||
|
* @deprecated Use delete() instead
|
||||||
|
* @param string $componentName
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function deleteDefaults(string $componentName): bool
|
||||||
|
{
|
||||||
|
$name = new ComponentName($componentName);
|
||||||
|
return $this->delete($name);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/Component/Infrastructure/README.md
Normal file
133
src/Component/Infrastructure/README.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Capa de Infraestructura
|
||||||
|
|
||||||
|
## 📋 Propósito
|
||||||
|
|
||||||
|
La **Capa de Infraestructura** contiene las implementaciones concretas de las interfaces definidas en el Dominio. Conecta la lógica de negocio con el mundo exterior (WordPress, MySQL, cache, HTTP).
|
||||||
|
|
||||||
|
## 🎯 Responsabilidades
|
||||||
|
|
||||||
|
- ✅ Implementar interfaces del Dominio
|
||||||
|
- ✅ Conectar con frameworks (WordPress)
|
||||||
|
- ✅ Persistencia (MySQL via wpdb)
|
||||||
|
- ✅ Cache (WordPress Transients)
|
||||||
|
- ✅ HTTP (AJAX endpoints)
|
||||||
|
- ✅ Contiene detalles de implementación
|
||||||
|
|
||||||
|
## 📦 Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
Infrastructure/
|
||||||
|
├── Persistence/
|
||||||
|
│ └── WordPress/
|
||||||
|
│ ├── WordPressComponentRepository.php (MySQL)
|
||||||
|
│ └── WordPressDefaultsRepository.php (Schemas)
|
||||||
|
├── Services/
|
||||||
|
│ ├── WordPressValidationService.php (Validación)
|
||||||
|
│ ├── WordPressCacheService.php (Transients)
|
||||||
|
│ ├── SchemaSyncService.php (JSON → BD)
|
||||||
|
│ └── CleanupService.php (Limpieza)
|
||||||
|
├── API/
|
||||||
|
│ └── WordPress/
|
||||||
|
│ └── AjaxController.php (Endpoints AJAX)
|
||||||
|
├── Facades/
|
||||||
|
│ └── ComponentManager.php (API unificada)
|
||||||
|
├── DI/
|
||||||
|
│ └── DIContainer.php (Dependency Injection)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 Implementaciones
|
||||||
|
|
||||||
|
### Repositories
|
||||||
|
|
||||||
|
#### WordPressComponentRepository
|
||||||
|
Persiste componentes en `wp_roi_theme_components`.
|
||||||
|
|
||||||
|
**Métodos**:
|
||||||
|
- `save(Component)`: INSERT o UPDATE
|
||||||
|
- `findByName(string)`: Buscar por nombre
|
||||||
|
- `findAll()`: Obtener todos
|
||||||
|
- `delete(string)`: Eliminar
|
||||||
|
|
||||||
|
#### WordPressDefaultsRepository
|
||||||
|
Gestiona schemas en `wp_roi_theme_defaults`.
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
#### WordPressValidationService
|
||||||
|
Valida datos contra schemas.
|
||||||
|
|
||||||
|
**Estrategia**:
|
||||||
|
1. Obtener schema de BD
|
||||||
|
2. Validar estructura
|
||||||
|
3. Sanitizar con funciones WordPress
|
||||||
|
|
||||||
|
#### WordPressCacheService
|
||||||
|
Cache con WordPress Transients API.
|
||||||
|
|
||||||
|
**TTL default**: 1 hora (3600 segundos)
|
||||||
|
|
||||||
|
#### SchemaSyncService
|
||||||
|
Sincroniza schemas desde JSON a BD.
|
||||||
|
|
||||||
|
#### CleanupService
|
||||||
|
Elimina componentes obsoletos.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
#### AjaxController
|
||||||
|
Endpoints AJAX de WordPress.
|
||||||
|
|
||||||
|
**Endpoints disponibles**:
|
||||||
|
- `roi_theme_save_component` (POST)
|
||||||
|
- `roi_theme_get_component` (GET)
|
||||||
|
- `roi_theme_delete_component` (POST)
|
||||||
|
- `roi_theme_sync_schema` (POST)
|
||||||
|
|
||||||
|
**Seguridad**:
|
||||||
|
- Nonce verification
|
||||||
|
- Capability check (manage_options)
|
||||||
|
|
||||||
|
### Facade
|
||||||
|
|
||||||
|
#### ComponentManager
|
||||||
|
API unificada para todo el sistema.
|
||||||
|
|
||||||
|
**Uso**:
|
||||||
|
```php
|
||||||
|
$manager = new ComponentManager($container);
|
||||||
|
$result = $manager->saveComponent('top_bar', $data);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📐 Principios Arquitectónicos
|
||||||
|
|
||||||
|
### Dependency Inversion
|
||||||
|
|
||||||
|
Infrastructure implementa interfaces del Domain:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class WordPressComponentRepository implements ComponentRepositoryInterface
|
||||||
|
{
|
||||||
|
// Implementación específica de WordPress
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Separación de Concerns
|
||||||
|
|
||||||
|
- **Repositories**: Solo persistencia
|
||||||
|
- **Services**: Lógica de infraestructura
|
||||||
|
- **Controller**: Solo HTTP
|
||||||
|
- **Facade**: Orquestación simple
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
Tests de **integración** (usan BD real de WordPress):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor\bin\phpunit tests\Integration\Infrastructure
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Referencias
|
||||||
|
|
||||||
|
- Domain Layer: `../Domain/README.md`
|
||||||
|
- Application Layer: `../Application/README.md`
|
||||||
51
src/Component/Infrastructure/Services/CleanupService.php
Normal file
51
src/Component/Infrastructure/Services/CleanupService.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Services;
|
||||||
|
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressComponentRepository;
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CleanupService - Limpieza de componentes obsoletos
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Eliminar componentes que ya no existen en schema
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Services
|
||||||
|
*/
|
||||||
|
final class CleanupService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WordPressComponentRepository $componentRepository,
|
||||||
|
private WordPressDefaultsRepository $defaultsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar componentes que no tienen schema
|
||||||
|
*
|
||||||
|
* @return array ['removed' => array]
|
||||||
|
*/
|
||||||
|
public function removeObsolete(): array
|
||||||
|
{
|
||||||
|
// Obtener todos los componentes actuales
|
||||||
|
$components = $this->componentRepository->findAll();
|
||||||
|
|
||||||
|
// Obtener schemas disponibles
|
||||||
|
$schemas = $this->defaultsRepository->findAll();
|
||||||
|
$validNames = array_keys($schemas);
|
||||||
|
|
||||||
|
$removed = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
$name = $component->name()->value();
|
||||||
|
|
||||||
|
// Si el componente no tiene schema, es obsoleto
|
||||||
|
if (!in_array($name, $validNames)) {
|
||||||
|
$this->componentRepository->delete($name);
|
||||||
|
$removed[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['removed' => $removed];
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/Component/Infrastructure/Services/SchemaSyncService.php
Normal file
152
src/Component/Infrastructure/Services/SchemaSyncService.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Services;
|
||||||
|
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SchemaSyncService - Sincronizar schemas JSON → BD
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Leer schemas desde archivos JSON y sincronizar con BD
|
||||||
|
*
|
||||||
|
* FLUJO:
|
||||||
|
* 1. Leer archivos JSON de schemas
|
||||||
|
* 2. Comparar con BD actual
|
||||||
|
* 3. Agregar/Actualizar/Eliminar según diferencias
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Services
|
||||||
|
*/
|
||||||
|
final class SchemaSyncService
|
||||||
|
{
|
||||||
|
private string $schemasPath;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private WordPressDefaultsRepository $defaultsRepository,
|
||||||
|
string $schemasPath
|
||||||
|
) {
|
||||||
|
$this->schemasPath = rtrim($schemasPath, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincronizar todos los schemas
|
||||||
|
*
|
||||||
|
* @return array ['success' => bool, 'data' => array]
|
||||||
|
*/
|
||||||
|
public function syncAll(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 1. Leer schemas desde JSON
|
||||||
|
$schemas = $this->readSchemasFromJson();
|
||||||
|
|
||||||
|
if (empty($schemas)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'No schemas found in JSON files'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener schemas actuales de BD
|
||||||
|
$currentSchemas = $this->defaultsRepository->findAll();
|
||||||
|
|
||||||
|
// 3. Determinar cambios
|
||||||
|
$schemaNames = array_keys($schemas);
|
||||||
|
$currentNames = array_keys($currentSchemas);
|
||||||
|
|
||||||
|
$toAdd = array_diff($schemaNames, $currentNames);
|
||||||
|
$toUpdate = array_intersect($schemaNames, $currentNames);
|
||||||
|
$toDelete = array_diff($currentNames, $schemaNames);
|
||||||
|
|
||||||
|
// 4. Aplicar cambios
|
||||||
|
$added = [];
|
||||||
|
$updated = [];
|
||||||
|
$deleted = [];
|
||||||
|
|
||||||
|
foreach ($toAdd as $name) {
|
||||||
|
$this->defaultsRepository->saveDefaults($name, $schemas[$name]);
|
||||||
|
$added[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($toUpdate as $name) {
|
||||||
|
$this->defaultsRepository->updateDefaults($name, $schemas[$name]);
|
||||||
|
$updated[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($toDelete as $name) {
|
||||||
|
$this->defaultsRepository->deleteDefaults($name);
|
||||||
|
$deleted[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'added' => $added,
|
||||||
|
'updated' => $updated,
|
||||||
|
'deleted' => $deleted
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincronizar un componente específico
|
||||||
|
*
|
||||||
|
* @param string $componentName
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function syncComponent(string $componentName): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$schemas = $this->readSchemasFromJson();
|
||||||
|
|
||||||
|
if (!isset($schemas[$componentName])) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => "Schema not found for: {$componentName}"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->defaultsRepository->saveDefaults($componentName, $schemas[$componentName]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['synced' => $componentName]
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leer schemas desde archivos JSON
|
||||||
|
*
|
||||||
|
* @return array Array asociativo [componentName => schema]
|
||||||
|
*/
|
||||||
|
private function readSchemasFromJson(): array
|
||||||
|
{
|
||||||
|
$schemasFile = $this->schemasPath . '/components-schema.json';
|
||||||
|
|
||||||
|
if (!file_exists($schemasFile)) {
|
||||||
|
throw new \RuntimeException("Schemas file not found: {$schemasFile}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($schemasFile);
|
||||||
|
$schemas = json_decode($content, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new \RuntimeException('Invalid JSON: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schemas;
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/Component/Infrastructure/Services/WordPressCacheService.php
Normal file
124
src/Component/Infrastructure/Services/WordPressCacheService.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Services;
|
||||||
|
|
||||||
|
use ROITheme\Component\Application\Contracts\CacheServiceInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPressCacheService - Cache con Transients API
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Gestionar cache de componentes
|
||||||
|
*
|
||||||
|
* IMPLEMENTACIÓN: WordPress Transients
|
||||||
|
* - get_transient()
|
||||||
|
* - set_transient()
|
||||||
|
* - delete_transient()
|
||||||
|
*
|
||||||
|
* VENTAJAS:
|
||||||
|
* - Compatible con object cache (Redis, Memcached)
|
||||||
|
* - Expiración automática
|
||||||
|
* - API simple
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Services
|
||||||
|
*/
|
||||||
|
final class WordPressCacheService implements CacheServiceInterface
|
||||||
|
{
|
||||||
|
private const PREFIX = 'roi_theme_';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private \wpdb $wpdb
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener valor del cache
|
||||||
|
*
|
||||||
|
* @param string $key Clave del cache
|
||||||
|
* @return mixed|null Valor o null si no existe/expiró
|
||||||
|
*/
|
||||||
|
public function get(string $key): mixed
|
||||||
|
{
|
||||||
|
$transient = get_transient($this->getFullKey($key));
|
||||||
|
|
||||||
|
// WordPress devuelve false si no existe
|
||||||
|
return $transient === false ? null : $transient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar valor en cache
|
||||||
|
*
|
||||||
|
* @param string $key Clave del cache
|
||||||
|
* @param mixed $value Valor a guardar
|
||||||
|
* @param int $expiration Tiempo de vida en segundos (default 1 hora)
|
||||||
|
* @return bool True si guardó exitosamente
|
||||||
|
*/
|
||||||
|
public function set(string $key, mixed $value, int $expiration = 3600): bool
|
||||||
|
{
|
||||||
|
return set_transient($this->getFullKey($key), $value, $expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar entrada de cache
|
||||||
|
*
|
||||||
|
* @param string $key Clave del cache
|
||||||
|
* @return bool True si eliminó exitosamente
|
||||||
|
*/
|
||||||
|
public function delete(string $key): bool
|
||||||
|
{
|
||||||
|
return $this->invalidate($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpiar todo el cache
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function flush(): bool
|
||||||
|
{
|
||||||
|
return $this->invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidar (eliminar) entrada de cache
|
||||||
|
*
|
||||||
|
* @param string $key Clave del cache
|
||||||
|
* @return bool True si eliminó exitosamente
|
||||||
|
*/
|
||||||
|
public function invalidate(string $key): bool
|
||||||
|
{
|
||||||
|
return delete_transient($this->getFullKey($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidar todo el cache de componentes
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function invalidateAll(): bool
|
||||||
|
{
|
||||||
|
// Obtener todos los componentes
|
||||||
|
$components = $this->wpdb->get_col(
|
||||||
|
"SELECT DISTINCT component_name FROM {$this->wpdb->prefix}roi_theme_components"
|
||||||
|
);
|
||||||
|
|
||||||
|
$success = true;
|
||||||
|
|
||||||
|
foreach ($components as $componentName) {
|
||||||
|
$result = $this->invalidate("component_{$componentName}");
|
||||||
|
$success = $success && $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener clave completa con prefijo
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getFullKey(string $key): string
|
||||||
|
{
|
||||||
|
return self::PREFIX . $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Component\Infrastructure\Services;
|
||||||
|
|
||||||
|
use ROITheme\Shared\Domain\ValidationServiceInterface;
|
||||||
|
use ROITheme\Shared\Domain\ValidationResult;
|
||||||
|
use ROITheme\Component\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPressValidationService - Validación contra schemas
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Validar y sanitizar datos de componentes
|
||||||
|
*
|
||||||
|
* ESTRATEGIA:
|
||||||
|
* 1. Obtener schema del componente desde BD
|
||||||
|
* 2. Validar estructura contra schema
|
||||||
|
* 3. Sanitizar datos usando funciones de WordPress
|
||||||
|
*
|
||||||
|
* @package ROITheme\Infrastructure\Services
|
||||||
|
*/
|
||||||
|
final class WordPressValidationService implements ValidationServiceInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WordPressDefaultsRepository $defaultsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar datos contra schema
|
||||||
|
*
|
||||||
|
* @param array $data Datos a validar
|
||||||
|
* @param string $componentName Nombre del componente (para obtener schema)
|
||||||
|
* @return ValidationResult
|
||||||
|
*/
|
||||||
|
public function validate(array $data, string $componentName): ValidationResult
|
||||||
|
{
|
||||||
|
// 1. Obtener schema
|
||||||
|
$schema = $this->defaultsRepository->find($componentName);
|
||||||
|
|
||||||
|
if ($schema === null) {
|
||||||
|
return ValidationResult::failure([
|
||||||
|
"Schema not found for component: {$componentName}"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sanitizar datos primero
|
||||||
|
$sanitized = $this->sanitize($data, $componentName);
|
||||||
|
|
||||||
|
// 3. Validar estructura
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($sanitized as $groupName => $fields) {
|
||||||
|
// Verificar que el grupo existe en schema
|
||||||
|
if (!isset($schema[$groupName])) {
|
||||||
|
$errors[$groupName] = "Unknown group: {$groupName}";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar cada campo del grupo
|
||||||
|
if (is_array($fields)) {
|
||||||
|
foreach ($fields as $key => $value) {
|
||||||
|
if (!isset($schema[$groupName][$key])) {
|
||||||
|
$errors["{$groupName}.{$key}"] = "Unknown field: {$groupName}.{$key}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validaciones adicionales pueden agregarse aquí
|
||||||
|
// Por ejemplo, validar tipos, rangos, formatos, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ValidationResult::failure($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult::success($sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizar datos recursivamente
|
||||||
|
*
|
||||||
|
* Usa funciones de WordPress según el tipo de dato
|
||||||
|
*
|
||||||
|
* @param array $data Datos a sanitizar
|
||||||
|
* @param string $componentName Nombre del componente
|
||||||
|
* @return array Datos sanitizados
|
||||||
|
*/
|
||||||
|
public function sanitize(array $data, string $componentName): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
// Recursivo para arrays anidados
|
||||||
|
$sanitized[$key] = $this->sanitizeValue($value);
|
||||||
|
} else {
|
||||||
|
$sanitized[$key] = $this->sanitizeValue($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizar un valor individual
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function sanitizeValue(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
$sanitized = [];
|
||||||
|
foreach ($value as $k => $v) {
|
||||||
|
$sanitized[$k] = $this->sanitizeValue($v);
|
||||||
|
}
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return (bool) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return is_float($value) ? (float) $value : (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
|
||||||
|
return esc_url_raw($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return sanitize_text_field($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar una URL
|
||||||
|
*
|
||||||
|
* @param string $url
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidUrl(string $url): bool
|
||||||
|
{
|
||||||
|
return filter_var($url, FILTER_VALIDATE_URL) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar un color hexadecimal
|
||||||
|
*
|
||||||
|
* @param string $color
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidColor(string $color): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^#[0-9A-F]{6}$/i', $color) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar nombre de componente
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidComponentName(string $name): bool
|
||||||
|
{
|
||||||
|
// Solo letras minúsculas, números y guiones bajos
|
||||||
|
return preg_match('/^[a-z0-9_]+$/', $name) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/Component/Infrastructure/UI/Assets/.gitkeep
Normal file
0
src/Component/Infrastructure/UI/Assets/.gitkeep
Normal file
0
src/Component/Infrastructure/UI/Views/.gitkeep
Normal file
0
src/Component/Infrastructure/UI/Views/.gitkeep
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\ContactFormSection\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class ContactFormSectionFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder contact-form-builder">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#section-tab" type="button">Sección</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#contact-info-tab" type="button">Información</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#form-tab" type="button">Formulario</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="section-tab"><?php echo $this->buildSectionTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="contact-info-tab"><?php echo $this->buildContactInfoTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="form-tab"><?php echo $this->buildFormTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSectionTab(): string
|
||||||
|
{
|
||||||
|
$showSection = $this->data['section']['show_section'] ?? true;
|
||||||
|
$sectionTitle = $this->data['section']['section_title'] ?? '¿Tienes alguna pregunta?';
|
||||||
|
$sectionSubtitle = $this->data['section']['section_subtitle'] ?? '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_section" name="section[show_section]" value="1" <?php checked($showSection, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_section"><strong>Mostrar sección de contacto</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="section_title"><strong>Título de la sección</strong></label>
|
||||||
|
<input type="text" class="form-control" id="section_title" name="section[section_title]" value="<?php echo esc_attr($sectionTitle); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="section_subtitle"><strong>Subtítulo</strong></label>
|
||||||
|
<textarea class="form-control" id="section_subtitle" name="section[section_subtitle]" rows="2"><?php echo esc_textarea($sectionSubtitle); ?></textarea>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContactInfoTab(): string
|
||||||
|
{
|
||||||
|
$contactInfo = $this->data['contact_info'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="phone_enabled" name="contact_info[phone_enabled]" value="1" <?php checked($contactInfo['phone_enabled'] ?? true, true); ?>>
|
||||||
|
<label class="form-check-label" for="phone_enabled"><strong>Teléfono</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="phone_label">Etiqueta</label>
|
||||||
|
<input type="text" class="form-control" id="phone_label" name="contact_info[phone_label]" value="<?php echo esc_attr($contactInfo['phone_label'] ?? 'Teléfono'); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="phone_value">Número</label>
|
||||||
|
<input type="text" class="form-control" id="phone_value" name="contact_info[phone_value]" value="<?php echo esc_attr($contactInfo['phone_value'] ?? '+52 55 1234 5678'); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="email_enabled" name="contact_info[email_enabled]" value="1" <?php checked($contactInfo['email_enabled'] ?? true, true); ?>>
|
||||||
|
<label class="form-check-label" for="email_enabled"><strong>Email</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="email_label">Etiqueta</label>
|
||||||
|
<input type="text" class="form-control" id="email_label" name="contact_info[email_label]" value="<?php echo esc_attr($contactInfo['email_label'] ?? 'Email'); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="email_value">Dirección</label>
|
||||||
|
<input type="email" class="form-control" id="email_value" name="contact_info[email_value]" value="<?php echo esc_attr($contactInfo['email_value'] ?? 'contacto@example.com'); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="location_enabled" name="contact_info[location_enabled]" value="1" <?php checked($contactInfo['location_enabled'] ?? true, true); ?>>
|
||||||
|
<label class="form-check-label" for="location_enabled"><strong>Ubicación</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="location_label">Etiqueta</label>
|
||||||
|
<input type="text" class="form-control" id="location_label" name="contact_info[location_label]" value="<?php echo esc_attr($contactInfo['location_label'] ?? 'Ubicación'); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="location_value">Ubicación</label>
|
||||||
|
<input type="text" class="form-control" id="location_value" name="contact_info[location_value]" value="<?php echo esc_attr($contactInfo['location_value'] ?? 'Ciudad de México, México'); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFormTab(): string
|
||||||
|
{
|
||||||
|
$form = $this->data['form'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="submit_button_text"><strong>Texto del botón de envío</strong></label>
|
||||||
|
<input type="text" class="form-control" id="submit_button_text" name="form[submit_button_text]" value="<?php echo esc_attr($form['submit_button_text'] ?? 'Enviar Mensaje'); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="submit_button_icon"><strong>Ícono del botón</strong></label>
|
||||||
|
<input type="text" class="form-control" id="submit_button_icon" name="form[submit_button_icon]" value="<?php echo esc_attr($form['submit_button_icon'] ?? 'bi-send-fill'); ?>">
|
||||||
|
<small class="form-text text-muted">Clase de Bootstrap Icons</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="success_message"><strong>Mensaje de éxito</strong></label>
|
||||||
|
<textarea class="form-control" id="success_message" name="form[success_message]" rows="2"><?php echo esc_textarea($form['success_message'] ?? '¡Gracias! Tu mensaje ha sido enviado correctamente.'); ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="error_message"><strong>Mensaje de error</strong></label>
|
||||||
|
<textarea class="form-control" id="error_message" name="form[error_message]" rows="2"><?php echo esc_textarea($form['error_message'] ?? 'Hubo un error al enviar el mensaje.'); ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="to_email"><strong>Email de destino</strong></label>
|
||||||
|
<input type="email" class="form-control" id="to_email" name="form[to_email]" value="<?php echo esc_attr($form['to_email'] ?? ''); ?>">
|
||||||
|
<small class="form-text text-muted">Deja vacío para usar el email del administrador</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(): string
|
||||||
|
{
|
||||||
|
$styles = $this->data['styles'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="background_color"><strong>Clase de fondo</strong></label>
|
||||||
|
<input type="text" class="form-control" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($styles['background_color'] ?? 'bg-secondary bg-opacity-25'); ?>">
|
||||||
|
<small class="form-text text-muted">Clase de Bootstrap (ej: bg-light, bg-secondary bg-opacity-25)</small>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="icon_color"><strong>Color de íconos</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="icon_color" name="styles[icon_color]" value="<?php echo esc_attr($styles['icon_color'] ?? '#FF8600'); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="button_bg_color"><strong>Color del botón</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="button_bg_color" name="styles[button_bg_color]" value="<?php echo esc_attr($styles['button_bg_color'] ?? '#FF8600'); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="button_hover_bg"><strong>Color del botón (hover)</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="button_hover_bg" name="styles[button_hover_bg]" value="<?php echo esc_attr($styles['button_hover_bg'] ?? '#FF6B00'); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\ContactFormSection\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class ContactFormSectionRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!($data['section']['show_section'] ?? true)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentId = $component->getId();
|
||||||
|
$sectionTitle = $data['section']['section_title'] ?? '¿Tienes alguna pregunta?';
|
||||||
|
$sectionSubtitle = $data['section']['section_subtitle'] ?? 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.';
|
||||||
|
$backgroundClass = $data['styles']['background_color'] ?? 'bg-secondary bg-opacity-25';
|
||||||
|
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<?php if (!empty($customStyles)): ?>
|
||||||
|
<style>
|
||||||
|
<?php echo $customStyles; ?>
|
||||||
|
</style>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section id="<?php echo esc_attr($componentId); ?>" class="contact-form-section <?php echo esc_attr($backgroundClass); ?> py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center mb-5">
|
||||||
|
<div class="col-lg-8 text-center">
|
||||||
|
<h2 class="h2 mb-3"><?php echo esc_html($sectionTitle); ?></h2>
|
||||||
|
<?php if (!empty($sectionSubtitle)): ?>
|
||||||
|
<p class="lead text-muted"><?php echo esc_html($sectionSubtitle); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<?php echo $this->renderContactInfo($data); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<?php echo $this->renderForm($componentId, $data); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderContactInfo(array $data): string
|
||||||
|
{
|
||||||
|
$contactInfo = $data['contact_info'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="contact-info h-100">
|
||||||
|
<h3 class="h4 mb-4">Información de contacto</h3>
|
||||||
|
<div class="contact-items">
|
||||||
|
<?php if ($contactInfo['phone_enabled'] ?? true): ?>
|
||||||
|
<div class="contact-item mb-4">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-telephone-fill contact-icon me-3"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="h6 mb-1"><?php echo esc_html($contactInfo['phone_label'] ?? 'Teléfono'); ?></h4>
|
||||||
|
<p class="mb-0"><?php echo esc_html($contactInfo['phone_value'] ?? '+52 55 1234 5678'); ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($contactInfo['email_enabled'] ?? true): ?>
|
||||||
|
<div class="contact-item mb-4">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-envelope-fill contact-icon me-3"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="h6 mb-1"><?php echo esc_html($contactInfo['email_label'] ?? 'Email'); ?></h4>
|
||||||
|
<p class="mb-0"><?php echo esc_html($contactInfo['email_value'] ?? 'contacto@example.com'); ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($contactInfo['location_enabled'] ?? true): ?>
|
||||||
|
<div class="contact-item mb-4">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-geo-alt-fill contact-icon me-3"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="h6 mb-1"><?php echo esc_html($contactInfo['location_label'] ?? 'Ubicación'); ?></h4>
|
||||||
|
<p class="mb-0"><?php echo esc_html($contactInfo['location_value'] ?? 'Ciudad de México, México'); ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderForm(string $componentId, array $data): string
|
||||||
|
{
|
||||||
|
$submitButtonText = $data['form']['submit_button_text'] ?? 'Enviar Mensaje';
|
||||||
|
$submitButtonIcon = $data['form']['submit_button_icon'] ?? 'bi-send-fill';
|
||||||
|
$successMessage = $data['form']['success_message'] ?? '¡Gracias! Tu mensaje ha sido enviado correctamente.';
|
||||||
|
$errorMessage = $data['form']['error_message'] ?? 'Hubo un error al enviar el mensaje.';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="contact-form-wrapper">
|
||||||
|
<form id="contact-form-<?php echo esc_attr($componentId); ?>" class="contact-form needs-validation" novalidate>
|
||||||
|
<?php wp_nonce_field('roi_contact_form_' . $componentId, 'roi_contact_nonce'); ?>
|
||||||
|
<input type="hidden" name="action" value="roi_contact_form_submit">
|
||||||
|
<input type="hidden" name="component_id" value="<?php echo esc_attr($componentId); ?>">
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input type="text" class="form-control" name="fullName" placeholder="Nombre completo *" required>
|
||||||
|
<div class="invalid-feedback">Por favor ingresa tu nombre</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input type="text" class="form-control" name="company" placeholder="Empresa">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input type="tel" class="form-control" name="whatsapp" placeholder="WhatsApp *" required>
|
||||||
|
<div class="invalid-feedback">Por favor ingresa tu WhatsApp</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input type="email" class="form-control" name="email" placeholder="Correo electrónico *" required>
|
||||||
|
<div class="invalid-feedback">Por favor ingresa un email válido</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<textarea class="form-control" name="comments" rows="4" placeholder="¿En qué podemos ayudarte?"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||||
|
<i class="bi <?php echo esc_attr($submitButtonIcon); ?> me-2"></i>
|
||||||
|
<?php echo esc_html($submitButtonText); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-3 d-none" role="alert" id="form-success-<?php echo esc_attr($componentId); ?>">
|
||||||
|
<?php echo esc_html($successMessage); ?>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger mt-3 d-none" role="alert" id="form-error-<?php echo esc_attr($componentId); ?>">
|
||||||
|
<?php echo esc_html($errorMessage); ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const form = document.getElementById('contact-form-<?php echo esc_js($componentId); ?>');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
e.stopPropagation();
|
||||||
|
form.classList.add('was-validated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
const successAlert = document.getElementById('form-success-<?php echo esc_js($componentId); ?>');
|
||||||
|
const errorAlert = document.getElementById('form-error-<?php echo esc_js($componentId); ?>');
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
successAlert.classList.add('d-none');
|
||||||
|
errorAlert.classList.add('d-none');
|
||||||
|
|
||||||
|
fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
successAlert.classList.remove('d-none');
|
||||||
|
form.reset();
|
||||||
|
form.classList.remove('was-validated');
|
||||||
|
} else {
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCustomStyles(string $componentId, array $styles): string
|
||||||
|
{
|
||||||
|
if (empty($styles)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$css = [];
|
||||||
|
|
||||||
|
if (isset($styles['icon_color'])) {
|
||||||
|
$css[] = "#$componentId .contact-icon { color: {$styles['icon_color']}; font-size: 1.5rem; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['button_bg_color'])) {
|
||||||
|
$css[] = "#$componentId .btn-primary { background-color: {$styles['button_bg_color']}; border-color: {$styles['button_bg_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['button_hover_bg'])) {
|
||||||
|
$css[] = "#$componentId .btn-primary:hover { background-color: {$styles['button_hover_bg']}; border-color: {$styles['button_hover_bg']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'contact-form-section';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\ContactModal\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class ContactModalFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder contact-modal-form-builder">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#general-tab" type="button">General</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#fields-tab" type="button">Campos</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#messages-tab" type="button">Mensajes</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#settings-tab" type="button">Configuración</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="general-tab"><?php echo $this->buildGeneralTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="fields-tab"><?php echo $this->buildFieldsTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="messages-tab"><?php echo $this->buildMessagesTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="settings-tab"><?php echo $this->buildSettingsTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildGeneralTab(): string
|
||||||
|
{
|
||||||
|
$general = $this->data['general'] ?? [];
|
||||||
|
$modalTitle = $general['modal_title'] ?? '¿Tienes alguna pregunta?';
|
||||||
|
$modalDescription = $general['modal_description'] ?? '';
|
||||||
|
$submitButton = $this->data['submit_button'] ?? [];
|
||||||
|
$buttonText = $submitButton['text'] ?? 'Enviar Mensaje';
|
||||||
|
$buttonIcon = $submitButton['icon'] ?? 'bi-send-fill';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="modal_title"><strong>Título del modal</strong></label>
|
||||||
|
<input type="text" class="form-control" id="modal_title" name="general[modal_title]" value="<?php echo esc_attr($modalTitle); ?>" required>
|
||||||
|
<small class="form-text text-muted">Título que aparece en el encabezado del modal</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="modal_description"><strong>Descripción</strong></label>
|
||||||
|
<textarea class="form-control" id="modal_description" name="general[modal_description]" rows="3"><?php echo esc_textarea($modalDescription); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Texto descriptivo debajo del título</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Botón de Envío</h5>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="submit_button_text"><strong>Texto del botón</strong></label>
|
||||||
|
<input type="text" class="form-control" id="submit_button_text" name="submit_button[text]" value="<?php echo esc_attr($buttonText); ?>" required>
|
||||||
|
<small class="form-text text-muted">Texto que aparece en el botón de envío</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="submit_button_icon"><strong>Ícono del botón</strong></label>
|
||||||
|
<input type="text" class="form-control" id="submit_button_icon" name="submit_button[icon]" value="<?php echo esc_attr($buttonIcon); ?>">
|
||||||
|
<small class="form-text text-muted">Clase de Bootstrap Icons (ej: bi-send-fill). <a href="https://icons.getbootstrap.com/" target="_blank">Ver íconos</a></small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFieldsTab(): string
|
||||||
|
{
|
||||||
|
$formFields = $this->data['form_fields'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Configure las etiquetas, placeholders y si cada campo es obligatorio o no.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php echo $this->buildFieldConfig('fullName', 'Nombre Completo', $formFields['fullName'] ?? []); ?>
|
||||||
|
<?php echo $this->buildFieldConfig('company', 'Empresa', $formFields['company'] ?? []); ?>
|
||||||
|
<?php echo $this->buildFieldConfig('whatsapp', 'WhatsApp', $formFields['whatsapp'] ?? []); ?>
|
||||||
|
<?php echo $this->buildFieldConfig('email', 'Correo Electrónico', $formFields['email'] ?? []); ?>
|
||||||
|
<?php echo $this->buildFieldConfig('comments', 'Comentarios', $formFields['comments'] ?? [], true); ?>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFieldConfig(string $fieldKey, string $fieldLabel, array $fieldData, bool $isTextarea = false): string
|
||||||
|
{
|
||||||
|
$label = $fieldData['label'] ?? '';
|
||||||
|
$placeholder = $fieldData['placeholder'] ?? '';
|
||||||
|
$required = $fieldData['required'] ?? false;
|
||||||
|
$rows = $fieldData['rows'] ?? 4;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0"><?php echo esc_html($fieldLabel); ?></h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5 mb-3">
|
||||||
|
<label for="<?php echo esc_attr($fieldKey); ?>_label">Etiqueta</label>
|
||||||
|
<input type="text" class="form-control" id="<?php echo esc_attr($fieldKey); ?>_label" name="form_fields[<?php echo esc_attr($fieldKey); ?>][label]" value="<?php echo esc_attr($label); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 mb-3">
|
||||||
|
<label for="<?php echo esc_attr($fieldKey); ?>_placeholder">Placeholder</label>
|
||||||
|
<input type="text" class="form-control" id="<?php echo esc_attr($fieldKey); ?>_placeholder" name="form_fields[<?php echo esc_attr($fieldKey); ?>][placeholder]" value="<?php echo esc_attr($placeholder); ?>">
|
||||||
|
</div>
|
||||||
|
<?php if ($isTextarea): ?>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<label for="<?php echo esc_attr($fieldKey); ?>_rows">Filas</label>
|
||||||
|
<input type="number" class="form-control" id="<?php echo esc_attr($fieldKey); ?>_rows" name="form_fields[<?php echo esc_attr($fieldKey); ?>][rows]" value="<?php echo esc_attr($rows); ?>" min="2" max="10">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="col-md-<?php echo $isTextarea ? '12' : '2'; ?>">
|
||||||
|
<div class="form-check form-switch" style="<?php echo $isTextarea ? '' : 'margin-top: 32px;'; ?>">
|
||||||
|
<input type="checkbox" class="form-check-input" id="<?php echo esc_attr($fieldKey); ?>_required" name="form_fields[<?php echo esc_attr($fieldKey); ?>][required]" value="1" <?php checked($required, true); ?>>
|
||||||
|
<label class="form-check-label" for="<?php echo esc_attr($fieldKey); ?>_required">
|
||||||
|
Campo requerido
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMessagesTab(): string
|
||||||
|
{
|
||||||
|
$messages = $this->data['messages'] ?? [];
|
||||||
|
$success = $messages['success'] ?? 'Mensaje enviado exitosamente. Te responderemos pronto.';
|
||||||
|
$error = $messages['error'] ?? 'Error al enviar el mensaje. Por favor intenta nuevamente.';
|
||||||
|
$validationError = $messages['validation_error'] ?? 'Por favor completa todos los campos requeridos.';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Configure los mensajes que se mostrarán al usuario según el resultado del envío del formulario.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="message_success"><strong>Mensaje de éxito</strong></label>
|
||||||
|
<textarea class="form-control" id="message_success" name="messages[success]" rows="2" required><?php echo esc_textarea($success); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Mensaje que se muestra cuando el formulario se envía correctamente</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="message_error"><strong>Mensaje de error</strong></label>
|
||||||
|
<textarea class="form-control" id="message_error" name="messages[error]" rows="2" required><?php echo esc_textarea($error); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Mensaje que se muestra cuando ocurre un error al enviar</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="message_validation_error"><strong>Mensaje de validación</strong></label>
|
||||||
|
<textarea class="form-control" id="message_validation_error" name="messages[validation_error]" rows="2" required><?php echo esc_textarea($validationError); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Mensaje que se muestra cuando faltan campos obligatorios</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSettingsTab(): string
|
||||||
|
{
|
||||||
|
$settings = $this->data['settings'] ?? [];
|
||||||
|
$modalId = $settings['modal_id'] ?? 'contactModal';
|
||||||
|
$formId = $settings['form_id'] ?? 'modalContactForm';
|
||||||
|
$ajaxAction = $settings['ajax_action'] ?? 'roi_contact_modal_submit';
|
||||||
|
$emailTo = $settings['email_to'] ?? '';
|
||||||
|
$emailSubject = $settings['email_subject'] ?? 'Nuevo mensaje de contacto desde el sitio web';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Configuración técnica del modal y envío de emails.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Configuración de Email</h5>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="email_to"><strong>Email de destino</strong></label>
|
||||||
|
<input type="email" class="form-control" id="email_to" name="settings[email_to]" value="<?php echo esc_attr($emailTo); ?>">
|
||||||
|
<small class="form-text text-muted">Email donde se recibirán los mensajes de contacto (vacío = admin email)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="email_subject"><strong>Asunto del email</strong></label>
|
||||||
|
<input type="text" class="form-control" id="email_subject" name="settings[email_subject]" value="<?php echo esc_attr($emailSubject); ?>" required>
|
||||||
|
<small class="form-text text-muted">Asunto del email que se enviará</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Configuración Técnica</h5>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<strong>Advertencia:</strong> Los siguientes campos no deben modificarse a menos que sepas lo que estás haciendo.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="modal_id"><strong>ID del modal</strong></label>
|
||||||
|
<input type="text" class="form-control" id="modal_id" name="settings[modal_id]" value="<?php echo esc_attr($modalId); ?>" readonly>
|
||||||
|
<small class="form-text text-muted">ID HTML del modal (no modificar)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="form_id"><strong>ID del formulario</strong></label>
|
||||||
|
<input type="text" class="form-control" id="form_id" name="settings[form_id]" value="<?php echo esc_attr($formId); ?>" readonly>
|
||||||
|
<small class="form-text text-muted">ID HTML del formulario (no modificar)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="ajax_action"><strong>Acción AJAX</strong></label>
|
||||||
|
<input type="text" class="form-control" id="ajax_action" name="settings[ajax_action]" value="<?php echo esc_attr($ajaxAction); ?>" readonly>
|
||||||
|
<small class="form-text text-muted">Nombre de la acción AJAX de WordPress (no modificar)</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\ContactModal\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class ContactModalRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
$componentId = $component->getId();
|
||||||
|
$modalId = $data['settings']['modal_id'] ?? 'contactModal';
|
||||||
|
$formId = $data['settings']['form_id'] ?? 'modalContactForm';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="modal fade" id="<?php echo esc_attr($modalId); ?>" tabindex="-1" aria-labelledby="<?php echo esc_attr($modalId); ?>Label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<?php echo $this->renderHeader($data, $modalId); ?>
|
||||||
|
<?php echo $this->renderBody($data, $formId, $componentId); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php echo $this->renderScript($data, $formId, $modalId, $componentId); ?>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderHeader(array $data, string $modalId): string
|
||||||
|
{
|
||||||
|
$title = $data['general']['modal_title'] ?? '¿Tienes alguna pregunta?';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="<?php echo esc_attr($modalId); ?>Label">
|
||||||
|
<i class="bi bi-envelope-fill me-2" style="color: #FF8600;"></i>
|
||||||
|
<?php echo esc_html($title); ?>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderBody(array $data, string $formId, string $componentId): string
|
||||||
|
{
|
||||||
|
$description = $data['general']['modal_description'] ?? '';
|
||||||
|
$ajaxAction = $data['settings']['ajax_action'] ?? 'roi_contact_modal_submit';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="modal-body">
|
||||||
|
<?php if (!empty($description)): ?>
|
||||||
|
<p class="mb-4"><?php echo esc_html($description); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form id="<?php echo esc_attr($formId); ?>" method="post" class="needs-validation" novalidate>
|
||||||
|
<?php wp_nonce_field('roi_contact_modal_' . $componentId, 'contact_nonce'); ?>
|
||||||
|
<input type="hidden" name="action" value="<?php echo esc_attr($ajaxAction); ?>">
|
||||||
|
<input type="hidden" name="component_id" value="<?php echo esc_attr($componentId); ?>">
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<?php echo $this->renderFormFields($data); ?>
|
||||||
|
<?php echo $this->renderSubmitButton($data); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-form-message-<?php echo esc_attr($componentId); ?>" class="alert mt-3 d-none" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderFormFields(array $data): string
|
||||||
|
{
|
||||||
|
$formFields = $data['form_fields'] ?? [];
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
// fullName
|
||||||
|
if (!empty($formFields['fullName'])) {
|
||||||
|
$html .= $this->renderTextField('fullName', 'modalFullName', $formFields['fullName'], 'col-md-6');
|
||||||
|
}
|
||||||
|
|
||||||
|
// company
|
||||||
|
if (!empty($formFields['company'])) {
|
||||||
|
$html .= $this->renderTextField('company', 'modalCompany', $formFields['company'], 'col-md-6');
|
||||||
|
}
|
||||||
|
|
||||||
|
// whatsapp
|
||||||
|
if (!empty($formFields['whatsapp'])) {
|
||||||
|
$html .= $this->renderTelField('whatsapp', 'modalWhatsapp', $formFields['whatsapp'], 'col-md-6');
|
||||||
|
}
|
||||||
|
|
||||||
|
// email
|
||||||
|
if (!empty($formFields['email'])) {
|
||||||
|
$html .= $this->renderEmailField('email', 'modalEmail', $formFields['email'], 'col-md-6');
|
||||||
|
}
|
||||||
|
|
||||||
|
// comments
|
||||||
|
if (!empty($formFields['comments'])) {
|
||||||
|
$html .= $this->renderTextareaField('comments', 'modalComments', $formFields['comments'], 'col-12');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTextField(string $name, string $id, array $fieldData, string $colClass): string
|
||||||
|
{
|
||||||
|
$label = $fieldData['label'] ?? '';
|
||||||
|
$placeholder = $fieldData['placeholder'] ?? '';
|
||||||
|
$required = $fieldData['required'] ?? false;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($colClass); ?>">
|
||||||
|
<label for="<?php echo esc_attr($id); ?>" class="form-label">
|
||||||
|
<?php echo esc_html($label); ?>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="<?php echo esc_attr($id); ?>"
|
||||||
|
name="<?php echo esc_attr($name); ?>"
|
||||||
|
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||||
|
<?php if ($required): ?>required<?php endif; ?>
|
||||||
|
>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<div class="invalid-feedback">Por favor completa este campo</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTelField(string $name, string $id, array $fieldData, string $colClass): string
|
||||||
|
{
|
||||||
|
$label = $fieldData['label'] ?? '';
|
||||||
|
$placeholder = $fieldData['placeholder'] ?? '';
|
||||||
|
$required = $fieldData['required'] ?? false;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($colClass); ?>">
|
||||||
|
<label for="<?php echo esc_attr($id); ?>" class="form-label">
|
||||||
|
<?php echo esc_html($label); ?>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="<?php echo esc_attr($id); ?>"
|
||||||
|
name="<?php echo esc_attr($name); ?>"
|
||||||
|
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||||
|
<?php if ($required): ?>required<?php endif; ?>
|
||||||
|
>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<div class="invalid-feedback">Por favor completa este campo</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderEmailField(string $name, string $id, array $fieldData, string $colClass): string
|
||||||
|
{
|
||||||
|
$label = $fieldData['label'] ?? '';
|
||||||
|
$placeholder = $fieldData['placeholder'] ?? '';
|
||||||
|
$required = $fieldData['required'] ?? false;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($colClass); ?>">
|
||||||
|
<label for="<?php echo esc_attr($id); ?>" class="form-label">
|
||||||
|
<?php echo esc_html($label); ?>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="<?php echo esc_attr($id); ?>"
|
||||||
|
name="<?php echo esc_attr($name); ?>"
|
||||||
|
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||||
|
<?php if ($required): ?>required<?php endif; ?>
|
||||||
|
>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<div class="invalid-feedback">Por favor ingresa un email válido</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTextareaField(string $name, string $id, array $fieldData, string $colClass): string
|
||||||
|
{
|
||||||
|
$label = $fieldData['label'] ?? '';
|
||||||
|
$placeholder = $fieldData['placeholder'] ?? '';
|
||||||
|
$required = $fieldData['required'] ?? false;
|
||||||
|
$rows = $fieldData['rows'] ?? 4;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($colClass); ?>">
|
||||||
|
<label for="<?php echo esc_attr($id); ?>" class="form-label">
|
||||||
|
<?php echo esc_html($label); ?>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="<?php echo esc_attr($id); ?>"
|
||||||
|
name="<?php echo esc_attr($name); ?>"
|
||||||
|
rows="<?php echo esc_attr($rows); ?>"
|
||||||
|
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||||
|
<?php if ($required): ?>required<?php endif; ?>
|
||||||
|
></textarea>
|
||||||
|
<?php if ($required): ?>
|
||||||
|
<div class="invalid-feedback">Por favor completa este campo</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderSubmitButton(array $data): string
|
||||||
|
{
|
||||||
|
$buttonText = $data['submit_button']['text'] ?? 'Enviar Mensaje';
|
||||||
|
$buttonIcon = $data['submit_button']['icon'] ?? 'bi-send-fill';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi <?php echo esc_attr($buttonIcon); ?> me-2"></i>
|
||||||
|
<?php echo esc_html($buttonText); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderScript(array $data, string $formId, string $modalId, string $componentId): string
|
||||||
|
{
|
||||||
|
$messages = $data['messages'] ?? [];
|
||||||
|
$successMessage = $messages['success'] ?? 'Mensaje enviado exitosamente';
|
||||||
|
$errorMessage = $messages['error'] ?? 'Error al enviar el mensaje';
|
||||||
|
$buttonText = $data['submit_button']['text'] ?? 'Enviar Mensaje';
|
||||||
|
$buttonIcon = $data['submit_button']['icon'] ?? 'bi-send-fill';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const form = document.getElementById('<?php echo esc_js($formId); ?>');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
e.stopPropagation();
|
||||||
|
form.classList.add('was-validated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
const messageContainer = document.getElementById('modal-form-message-<?php echo esc_js($componentId); ?>');
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enviando...';
|
||||||
|
messageContainer.classList.add('d-none');
|
||||||
|
|
||||||
|
fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
messageContainer.classList.remove('alert-danger');
|
||||||
|
messageContainer.classList.add('alert-success');
|
||||||
|
messageContainer.textContent = data.data?.message || '<?php echo esc_js($successMessage); ?>';
|
||||||
|
messageContainer.classList.remove('d-none');
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.classList.remove('was-validated');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('<?php echo esc_js($modalId); ?>'));
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
messageContainer.classList.add('d-none');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
messageContainer.classList.remove('alert-success');
|
||||||
|
messageContainer.classList.add('alert-danger');
|
||||||
|
messageContainer.textContent = data.data?.message || '<?php echo esc_js($errorMessage); ?>';
|
||||||
|
messageContainer.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
messageContainer.classList.remove('alert-success');
|
||||||
|
messageContainer.classList.add('alert-danger');
|
||||||
|
messageContainer.textContent = '<?php echo esc_js($errorMessage); ?>';
|
||||||
|
messageContainer.classList.remove('d-none');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="bi <?php echo esc_js($buttonIcon); ?> me-2"></i><?php echo esc_js($buttonText); ?>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpiar mensajes al cerrar el modal
|
||||||
|
const modalElement = document.getElementById('<?php echo esc_js($modalId); ?>');
|
||||||
|
if (modalElement) {
|
||||||
|
modalElement.addEventListener('hidden.bs.modal', function() {
|
||||||
|
const messageContainer = document.getElementById('modal-form-message-<?php echo esc_js($componentId); ?>');
|
||||||
|
if (messageContainer) {
|
||||||
|
messageContainer.classList.add('d-none');
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
form.classList.remove('was-validated');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'contact-modal';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Footer\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class FooterFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder footer-form-builder">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#widgets-tab" type="button">Widgets</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#newsletter-tab" type="button">Newsletter</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#copyright-tab" type="button">Copyright</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#social-tab" type="button">Redes Sociales</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="widgets-tab"><?php echo $this->buildWidgetsTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="newsletter-tab"><?php echo $this->buildNewsletterTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="copyright-tab"><?php echo $this->buildCopyrightTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="social-tab"><?php echo $this->buildSocialTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
jQuery(document).ready(function($) {
|
||||||
|
// Función para añadir enlaces a los widgets
|
||||||
|
$('.add-widget-link').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const widgetKey = $(this).data('widget');
|
||||||
|
const container = $('#' + widgetKey + '-links-container');
|
||||||
|
const index = container.find('.widget-link-item').length;
|
||||||
|
|
||||||
|
const template = `
|
||||||
|
<div class="widget-link-item card mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5 mb-2">
|
||||||
|
<label>Texto del enlace</label>
|
||||||
|
<input type="text" class="form-control" name="${widgetKey}[links][${index}][text]" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 mb-2">
|
||||||
|
<label>URL</label>
|
||||||
|
<input type="text" class="form-control" name="${widgetKey}[links][${index}][url]" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-2 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm remove-widget-link w-100">
|
||||||
|
<i class="bi bi-trash"></i> Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.append(template);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Función para eliminar enlaces
|
||||||
|
$(document).on('click', '.remove-widget-link', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
$(this).closest('.widget-link-item').remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildWidgetsTab(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<?php echo $this->buildWidgetColumn('widget_1', 'Widget 1'); ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<?php echo $this->buildWidgetColumn('widget_2', 'Widget 2'); ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<?php echo $this->buildWidgetColumn('widget_3', 'Widget 3'); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildWidgetColumn(string $widgetKey, string $label): string
|
||||||
|
{
|
||||||
|
$widgetData = $this->data[$widgetKey] ?? [];
|
||||||
|
$enabled = $widgetData['enabled'] ?? true;
|
||||||
|
$title = $widgetData['title'] ?? '';
|
||||||
|
$links = $widgetData['links'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><?php echo esc_html($label); ?></h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="<?php echo esc_attr($widgetKey); ?>_enabled" name="<?php echo esc_attr($widgetKey); ?>[enabled]" value="1" <?php checked($enabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="<?php echo esc_attr($widgetKey); ?>_enabled">
|
||||||
|
<strong>Activar este widget</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="<?php echo esc_attr($widgetKey); ?>_title"><strong>Título del widget</strong></label>
|
||||||
|
<input type="text" class="form-control" id="<?php echo esc_attr($widgetKey); ?>_title" name="<?php echo esc_attr($widgetKey); ?>[title]" value="<?php echo esc_attr($title); ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label><strong>Enlaces</strong></label>
|
||||||
|
<div id="<?php echo esc_attr($widgetKey); ?>-links-container">
|
||||||
|
<?php if (!empty($links)): ?>
|
||||||
|
<?php foreach ($links as $index => $link): ?>
|
||||||
|
<div class="widget-link-item card mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5 mb-2">
|
||||||
|
<label>Texto del enlace</label>
|
||||||
|
<input type="text" class="form-control" name="<?php echo esc_attr($widgetKey); ?>[links][<?php echo esc_attr($index); ?>][text]" value="<?php echo esc_attr($link['text'] ?? ''); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 mb-2">
|
||||||
|
<label>URL</label>
|
||||||
|
<input type="text" class="form-control" name="<?php echo esc_attr($widgetKey); ?>[links][<?php echo esc_attr($index); ?>][url]" value="<?php echo esc_attr($link['url'] ?? ''); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-2 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm remove-widget-link w-100">
|
||||||
|
<i class="bi bi-trash"></i> Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary add-widget-link mt-2" data-widget="<?php echo esc_attr($widgetKey); ?>">
|
||||||
|
<i class="bi bi-plus-circle"></i> Añadir enlace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNewsletterTab(): string
|
||||||
|
{
|
||||||
|
$newsletter = $this->data['newsletter'] ?? [];
|
||||||
|
$enabled = $newsletter['enabled'] ?? true;
|
||||||
|
$title = $newsletter['title'] ?? 'Suscríbete a nuestro newsletter';
|
||||||
|
$description = $newsletter['description'] ?? '';
|
||||||
|
$placeholder = $newsletter['placeholder'] ?? 'Correo electrónico';
|
||||||
|
$buttonText = $newsletter['button_text'] ?? 'Suscribirse';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="newsletter_enabled" name="newsletter[enabled]" value="1" <?php checked($enabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="newsletter_enabled">
|
||||||
|
<strong>Activar sección de newsletter</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="newsletter_title"><strong>Título</strong></label>
|
||||||
|
<input type="text" class="form-control" id="newsletter_title" name="newsletter[title]" value="<?php echo esc_attr($title); ?>" required>
|
||||||
|
<small class="form-text text-muted">Título de la sección de newsletter</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="newsletter_description"><strong>Descripción</strong></label>
|
||||||
|
<textarea class="form-control" id="newsletter_description" name="newsletter[description]" rows="3"><?php echo esc_textarea($description); ?></textarea>
|
||||||
|
<small class="form-text text-muted">Texto descriptivo debajo del título</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="newsletter_placeholder"><strong>Placeholder del campo</strong></label>
|
||||||
|
<input type="text" class="form-control" id="newsletter_placeholder" name="newsletter[placeholder]" value="<?php echo esc_attr($placeholder); ?>">
|
||||||
|
<small class="form-text text-muted">Texto placeholder del campo de email</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="newsletter_button_text"><strong>Texto del botón</strong></label>
|
||||||
|
<input type="text" class="form-control" id="newsletter_button_text" name="newsletter[button_text]" value="<?php echo esc_attr($buttonText); ?>" required>
|
||||||
|
<small class="form-text text-muted">Texto del botón de suscripción</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
<strong>Nota:</strong> Para configurar el servicio de email de newsletter, ve a la sección de configuración general del tema.
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCopyrightTab(): string
|
||||||
|
{
|
||||||
|
$copyright = $this->data['copyright'] ?? [];
|
||||||
|
$text = $copyright['text'] ?? 'ROI Theme. Todos los derechos reservados.';
|
||||||
|
$yearAuto = $copyright['year_auto'] ?? true;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="copyright_text"><strong>Texto de copyright</strong></label>
|
||||||
|
<input type="text" class="form-control" id="copyright_text" name="copyright[text]" value="<?php echo esc_attr($text); ?>" required>
|
||||||
|
<small class="form-text text-muted">Texto que aparece en el footer</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="copyright_year_auto" name="copyright[year_auto]" value="1" <?php checked($yearAuto, true); ?>>
|
||||||
|
<label class="form-check-label" for="copyright_year_auto">
|
||||||
|
<strong>Mostrar año actual automáticamente</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Si está activado, mostrará el año actual antes del texto de copyright</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<strong>Vista previa:</strong><br>
|
||||||
|
<?php if ($yearAuto): ?>
|
||||||
|
© <?php echo date('Y'); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php echo esc_html($text); ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSocialTab(): string
|
||||||
|
{
|
||||||
|
$social = $this->data['social_links'] ?? [];
|
||||||
|
$twitter = $social['twitter'] ?? '';
|
||||||
|
$instagram = $social['instagram'] ?? '';
|
||||||
|
$facebook = $social['facebook'] ?? '';
|
||||||
|
$linkedin = $social['linkedin'] ?? '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="text-muted">
|
||||||
|
Configure los enlaces a sus redes sociales. Deje vacío para ocultar la red social correspondiente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="social_twitter">
|
||||||
|
<i class="bi bi-twitter text-info"></i> <strong>Twitter</strong>
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control" id="social_twitter" name="social_links[twitter]" value="<?php echo esc_attr($twitter); ?>" placeholder="https://twitter.com/usuario">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="social_instagram">
|
||||||
|
<i class="bi bi-instagram text-danger"></i> <strong>Instagram</strong>
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control" id="social_instagram" name="social_links[instagram]" value="<?php echo esc_attr($instagram); ?>" placeholder="https://instagram.com/usuario">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="social_facebook">
|
||||||
|
<i class="bi bi-facebook text-primary"></i> <strong>Facebook</strong>
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control" id="social_facebook" name="social_links[facebook]" value="<?php echo esc_attr($facebook); ?>" placeholder="https://facebook.com/pagina">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="social_linkedin">
|
||||||
|
<i class="bi bi-linkedin text-primary"></i> <strong>LinkedIn</strong>
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control" id="social_linkedin" name="social_links[linkedin]" value="<?php echo esc_attr($linkedin); ?>" placeholder="https://linkedin.com/company/empresa">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(): string
|
||||||
|
{
|
||||||
|
$styles = $this->data['styles'] ?? [];
|
||||||
|
$backgroundColor = $styles['background_color'] ?? 'bg-dark';
|
||||||
|
$textColor = $styles['text_color'] ?? 'text-white';
|
||||||
|
$linkHoverColor = $styles['link_hover_color'] ?? '#FF8600';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="background_color"><strong>Clase de fondo</strong></label>
|
||||||
|
<input type="text" class="form-control" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($backgroundColor); ?>">
|
||||||
|
<small class="form-text text-muted">Clase de Bootstrap (ej: bg-dark, bg-secondary, bg-primary)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="text_color"><strong>Clase de color de texto</strong></label>
|
||||||
|
<input type="text" class="form-control" id="text_color" name="styles[text_color]" value="<?php echo esc_attr($textColor); ?>">
|
||||||
|
<small class="form-text text-muted">Clase de Bootstrap (ej: text-white, text-light, text-dark)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="link_hover_color"><strong>Color de enlaces al hover</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="link_hover_color" name="styles[link_hover_color]" value="<?php echo esc_attr($linkHoverColor); ?>">
|
||||||
|
<small class="form-text text-muted">Color que se mostrará cuando se pase el mouse sobre los enlaces</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-palette"></i>
|
||||||
|
<strong>Sugerencia:</strong> Las clases de Bootstrap disponibles incluyen: bg-primary, bg-secondary, bg-success, bg-danger, bg-warning, bg-info, bg-light, bg-dark
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/Footer/Infrastructure/Presentation/Public/FooterRenderer.php
Normal file
273
src/Footer/Infrastructure/Presentation/Public/FooterRenderer.php
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Footer\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class FooterRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
$componentId = $component->getId();
|
||||||
|
$backgroundClass = $data['styles']['background_color'] ?? 'bg-dark';
|
||||||
|
$textClass = $data['styles']['text_color'] ?? 'text-white';
|
||||||
|
$customStyles = $this->generateCustomStyles($data['styles'] ?? []);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<?php if (!empty($customStyles)): ?>
|
||||||
|
<style>
|
||||||
|
<?php echo $customStyles; ?>
|
||||||
|
</style>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<footer id="<?php echo esc_attr($componentId); ?>" class="py-5 mt-0 <?php echo esc_attr($backgroundClass . ' ' . $textClass); ?>">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<?php echo $this->renderWidget($data['widget_1'] ?? [], 'col-6 col-md-2 mb-3'); ?>
|
||||||
|
<?php echo $this->renderWidget($data['widget_2'] ?? [], 'col-6 col-md-2 mb-3'); ?>
|
||||||
|
<?php echo $this->renderWidget($data['widget_3'] ?? [], 'col-6 col-md-2 mb-3'); ?>
|
||||||
|
<?php echo $this->renderNewsletter($componentId, $data['newsletter'] ?? []); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-between py-4 my-4 border-top">
|
||||||
|
<?php echo $this->renderCopyright($data['copyright'] ?? []); ?>
|
||||||
|
<?php echo $this->renderSocialLinks($data['social_links'] ?? []); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderWidget(array $widgetData, string $colClasses): string
|
||||||
|
{
|
||||||
|
if (empty($widgetData) || !($widgetData['enabled'] ?? true)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $widgetData['title'] ?? '';
|
||||||
|
$links = $widgetData['links'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($colClasses); ?>">
|
||||||
|
<?php if (!empty($title)): ?>
|
||||||
|
<h5><?php echo esc_html($title); ?></h5>
|
||||||
|
<?php endif; ?>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<?php foreach ($links as $link): ?>
|
||||||
|
<?php echo $this->renderLink($link); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderLink(array $link): string
|
||||||
|
{
|
||||||
|
if (empty($link['text'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $link['url'] ?? '#';
|
||||||
|
$text = $link['text'] ?? '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<li class="nav-item mb-2">
|
||||||
|
<a href="<?php echo esc_url($url); ?>" class="nav-link p-0 text-white-50 footer-link">
|
||||||
|
<?php echo esc_html($text); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderNewsletter(string $componentId, array $newsletterData): string
|
||||||
|
{
|
||||||
|
if (empty($newsletterData) || !($newsletterData['enabled'] ?? true)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $newsletterData['title'] ?? 'Suscríbete a nuestro newsletter';
|
||||||
|
$description = $newsletterData['description'] ?? '';
|
||||||
|
$placeholder = $newsletterData['placeholder'] ?? 'Correo electrónico';
|
||||||
|
$buttonText = $newsletterData['button_text'] ?? 'Suscribirse';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="col-md-5 offset-md-1 mb-3">
|
||||||
|
<form id="footer-newsletter-form-<?php echo esc_attr($componentId); ?>" class="footer-newsletter-form" method="post">
|
||||||
|
<?php wp_nonce_field('roi_newsletter_' . $componentId, 'newsletter_nonce'); ?>
|
||||||
|
<input type="hidden" name="action" value="roi_newsletter_subscribe">
|
||||||
|
<input type="hidden" name="component_id" value="<?php echo esc_attr($componentId); ?>">
|
||||||
|
|
||||||
|
<h5><?php echo esc_html($title); ?></h5>
|
||||||
|
<?php if (!empty($description)): ?>
|
||||||
|
<p><?php echo esc_html($description); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row w-100 gap-2">
|
||||||
|
<label for="newsletter-email-<?php echo esc_attr($componentId); ?>" class="visually-hidden">
|
||||||
|
<?php echo esc_html($placeholder); ?>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="newsletter-email-<?php echo esc_attr($componentId); ?>"
|
||||||
|
type="email"
|
||||||
|
name="newsletter_email"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<?php echo esc_html($buttonText); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-3 d-none" role="alert" id="newsletter-success-<?php echo esc_attr($componentId); ?>">
|
||||||
|
¡Gracias por suscribirte! Te hemos enviado un email de confirmación.
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger mt-3 d-none" role="alert" id="newsletter-error-<?php echo esc_attr($componentId); ?>">
|
||||||
|
Hubo un error al procesar tu suscripción. Por favor, intenta de nuevo.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const form = document.getElementById('footer-newsletter-form-<?php echo esc_js($componentId); ?>');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
const successAlert = document.getElementById('newsletter-success-<?php echo esc_js($componentId); ?>');
|
||||||
|
const errorAlert = document.getElementById('newsletter-error-<?php echo esc_js($componentId); ?>');
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
successAlert.classList.add('d-none');
|
||||||
|
errorAlert.classList.add('d-none');
|
||||||
|
|
||||||
|
fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
successAlert.classList.remove('d-none');
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderCopyright(array $copyrightData): string
|
||||||
|
{
|
||||||
|
if (empty($copyrightData)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $copyrightData['text'] ?? 'Todos los derechos reservados.';
|
||||||
|
$yearAuto = $copyrightData['year_auto'] ?? true;
|
||||||
|
$year = $yearAuto ? date('Y') : '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="mb-0">
|
||||||
|
<?php if (!empty($year)): ?>
|
||||||
|
© <?php echo esc_html($year); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php echo esc_html($text); ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderSocialLinks(array $socialData): string
|
||||||
|
{
|
||||||
|
if (empty($socialData)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$networks = [
|
||||||
|
'twitter' => 'bi-twitter',
|
||||||
|
'instagram' => 'bi-instagram',
|
||||||
|
'facebook' => 'bi-facebook',
|
||||||
|
'linkedin' => 'bi-linkedin'
|
||||||
|
];
|
||||||
|
|
||||||
|
$hasLinks = false;
|
||||||
|
foreach ($networks as $network => $icon) {
|
||||||
|
if (!empty($socialData[$network])) {
|
||||||
|
$hasLinks = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasLinks) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<ul class="list-unstyled d-flex mb-0">
|
||||||
|
<?php foreach ($networks as $network => $icon): ?>
|
||||||
|
<?php if (!empty($socialData[$network])): ?>
|
||||||
|
<li class="ms-3">
|
||||||
|
<a class="link-light footer-social-link" href="<?php echo esc_url($socialData[$network]); ?>" target="_blank" rel="noopener noreferrer" aria-label="<?php echo esc_attr(ucfirst($network)); ?>">
|
||||||
|
<i class="bi <?php echo esc_attr($icon); ?>" style="font-size: 1.25rem;"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCustomStyles(array $styles): string
|
||||||
|
{
|
||||||
|
if (empty($styles)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$css = [];
|
||||||
|
|
||||||
|
if (isset($styles['link_hover_color'])) {
|
||||||
|
$css[] = "footer .footer-link:hover { color: {$styles['link_hover_color']} !important; }";
|
||||||
|
$css[] = "footer .footer-social-link:hover { color: {$styles['link_hover_color']} !important; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'footer';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\HeroSection\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class HeroSectionFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
$data = $this->data;
|
||||||
|
$componentId = $this->componentId;
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-builder roi-hero-section-form">';
|
||||||
|
$html .= $this->buildTabsNavigation();
|
||||||
|
$html .= '<div class="tab-content mt-3">';
|
||||||
|
$html .= $this->buildVisibilityTab($data, $componentId);
|
||||||
|
$html .= $this->buildCategoriesTab($data, $componentId);
|
||||||
|
$html .= $this->buildTitleTab($data, $componentId);
|
||||||
|
$html .= $this->buildStylesTab($data, $componentId);
|
||||||
|
$html .= '</div></div>';
|
||||||
|
$html .= $this->buildFormScripts($componentId);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTabsNavigation(): string
|
||||||
|
{
|
||||||
|
return <<<HTML
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button></li>
|
||||||
|
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#categories-tab" type="button">Categorías</button></li>
|
||||||
|
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#title-tab" type="button">Título</button></li>
|
||||||
|
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button></li>
|
||||||
|
</ul>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilityTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade show active" id="visibility-tab"><div class="p-3">';
|
||||||
|
$isEnabled = $data['visibility']['is_enabled'] ?? true;
|
||||||
|
$html .= $this->buildToggle('is_enabled', 'Mostrar hero section', $isEnabled, $componentId, 'visibility');
|
||||||
|
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||||
|
$html .= $this->buildSelect('show_on_pages', 'Mostrar en', $showOn, ['all' => 'Todas las páginas', 'home' => 'Solo página de inicio', 'posts' => 'Solo posts individuales', 'pages' => 'Solo páginas', 'custom' => 'Tipos de post específicos'], $componentId, 'visibility');
|
||||||
|
$customPostTypes = $data['visibility']['custom_post_types'] ?? '';
|
||||||
|
$html .= $this->buildTextField('custom_post_types', 'Tipos de post personalizados', $customPostTypes, $componentId, 'visibility', 'Ej: post,page,producto', ['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']);
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCategoriesTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="categories-tab"><div class="p-3">';
|
||||||
|
$showCategories = $data['categories']['show_categories'] ?? true;
|
||||||
|
$html .= $this->buildToggle('show_categories', 'Mostrar badges de categorías', $showCategories, $componentId, 'categories');
|
||||||
|
$categoriesSource = $data['categories']['categories_source'] ?? 'post_categories';
|
||||||
|
$html .= $this->buildSelect('categories_source', 'Fuente de categorías', $categoriesSource, ['post_categories' => 'Categorías del post', 'post_tags' => 'Etiquetas del post', 'custom_taxonomy' => 'Taxonomía personalizada', 'custom_list' => 'Lista personalizada'], $componentId, 'categories', ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
|
||||||
|
$customTaxonomy = $data['categories']['custom_taxonomy_name'] ?? '';
|
||||||
|
$html .= $this->buildTextField('custom_taxonomy_name', 'Nombre de taxonomía personalizada', $customTaxonomy, $componentId, 'categories', 'Ej: project_category', ['data-conditional-field' => 'categories_source', 'data-conditional-value' => 'custom_taxonomy']);
|
||||||
|
$customList = $data['categories']['custom_categories_list'] ?? '';
|
||||||
|
$html .= $this->buildTextArea('custom_categories_list', 'Lista personalizada de categorías', $customList, $componentId, 'categories', 'Análisis de Precios|#', 5, ['data-conditional-field' => 'categories_source', 'data-conditional-value' => 'custom_list']);
|
||||||
|
$maxCategories = $data['categories']['max_categories'] ?? 5;
|
||||||
|
$html .= $this->buildNumberField('max_categories', 'Máximo de categorías a mostrar', $maxCategories, $componentId, 'categories', 1, 20, ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
|
||||||
|
$categoryIcon = $data['categories']['category_icon'] ?? 'bi-folder-fill';
|
||||||
|
$html .= $this->buildTextField('category_icon', 'Ícono de categoría', $categoryIcon, $componentId, 'categories', 'Ej: bi-folder-fill', ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
|
||||||
|
$categoriesAlignment = $data['categories']['categories_alignment'] ?? 'center';
|
||||||
|
$html .= $this->buildSelect('categories_alignment', 'Alineación de categorías', $categoriesAlignment, ['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'], $componentId, 'categories', ['data-conditional-field' => 'show_categories', 'data-conditional-value' => 'true']);
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTitleTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="title-tab"><div class="p-3">';
|
||||||
|
$titleSource = $data['title']['title_source'] ?? 'post_title';
|
||||||
|
$html .= $this->buildSelect('title_source', 'Fuente del título', $titleSource, ['post_title' => 'Título del post', 'custom_field' => 'Campo personalizado', 'custom_text' => 'Texto personalizado'], $componentId, 'title');
|
||||||
|
$customField = $data['title']['custom_field_name'] ?? '';
|
||||||
|
$html .= $this->buildTextField('custom_field_name', 'Nombre del campo personalizado', $customField, $componentId, 'title', 'Ej: hero_title', ['data-conditional-field' => 'title_source', 'data-conditional-value' => 'custom_field']);
|
||||||
|
$customText = $data['title']['custom_text'] ?? '';
|
||||||
|
$html .= $this->buildTextArea('custom_text', 'Texto personalizado', $customText, $componentId, 'title', '', 3, ['data-conditional-field' => 'title_source', 'data-conditional-value' => 'custom_text']);
|
||||||
|
$titleTag = $data['title']['title_tag'] ?? 'h1';
|
||||||
|
$html .= $this->buildSelect('title_tag', 'Etiqueta HTML del título', $titleTag, ['h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'div' => 'DIV'], $componentId, 'title');
|
||||||
|
$titleClasses = $data['title']['title_classes'] ?? 'display-5 fw-bold';
|
||||||
|
$html .= $this->buildTextField('title_classes', 'Clases CSS adicionales', $titleClasses, $componentId, 'title', 'Ej: display-5 fw-bold');
|
||||||
|
$titleAlignment = $data['title']['title_alignment'] ?? 'center';
|
||||||
|
$html .= $this->buildSelect('title_alignment', 'Alineación del título', $titleAlignment, ['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'], $componentId, 'title');
|
||||||
|
$enableGradient = $data['title']['enable_gradient'] ?? false;
|
||||||
|
$html .= $this->buildToggle('enable_gradient', 'Activar gradiente en el texto', $enableGradient, $componentId, 'title');
|
||||||
|
$gradientStart = $data['title']['gradient_color_start'] ?? '#1e3a5f';
|
||||||
|
$html .= $this->buildColorField('gradient_color_start', 'Color inicial del gradiente', $gradientStart, $componentId, 'title', ['data-conditional-field' => 'enable_gradient', 'data-conditional-value' => 'true']);
|
||||||
|
$gradientEnd = $data['title']['gradient_color_end'] ?? '#FF8600';
|
||||||
|
$html .= $this->buildColorField('gradient_color_end', 'Color final del gradiente', $gradientEnd, $componentId, 'title', ['data-conditional-field' => 'enable_gradient', 'data-conditional-value' => 'true']);
|
||||||
|
$gradientDirection = $data['title']['gradient_direction'] ?? 'to-right';
|
||||||
|
$html .= $this->buildSelect('gradient_direction', 'Dirección del gradiente', $gradientDirection, ['to-right' => 'Izquierda a derecha', 'to-left' => 'Derecha a izquierda', 'to-bottom' => 'Arriba a abajo', 'to-top' => 'Abajo a arriba', 'diagonal' => 'Diagonal'], $componentId, 'title', ['data-conditional-field' => 'enable_gradient', 'data-conditional-value' => 'true']);
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="styles-tab"><div class="p-3">';
|
||||||
|
$backgroundType = $data['styles']['background_type'] ?? 'gradient';
|
||||||
|
$html .= $this->buildSelect('background_type', 'Tipo de fondo', $backgroundType, ['color' => 'Color sólido', 'gradient' => 'Gradiente', 'image' => 'Imagen', 'none' => 'Sin fondo'], $componentId, 'styles');
|
||||||
|
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
|
||||||
|
$html .= $this->buildColorField('background_color', 'Color de fondo', $bgColor, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'color']);
|
||||||
|
$gradientStart = $data['styles']['gradient_start_color'] ?? '#1e3a5f';
|
||||||
|
$html .= $this->buildColorField('gradient_start_color', 'Color inicial del gradiente', $gradientStart, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'gradient']);
|
||||||
|
$gradientEnd = $data['styles']['gradient_end_color'] ?? '#2c5282';
|
||||||
|
$html .= $this->buildColorField('gradient_end_color', 'Color final del gradiente', $gradientEnd, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'gradient']);
|
||||||
|
$gradientAngle = $data['styles']['gradient_angle'] ?? 135;
|
||||||
|
$html .= $this->buildNumberField('gradient_angle', 'Ángulo del gradiente (grados)', $gradientAngle, $componentId, 'styles', 0, 360, ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'gradient']);
|
||||||
|
$bgImage = $data['styles']['background_image_url'] ?? '';
|
||||||
|
$html .= $this->buildMediaField('background_image_url', 'Imagen de fondo', $bgImage, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'image']);
|
||||||
|
$bgOverlay = $data['styles']['background_overlay'] ?? true;
|
||||||
|
$html .= $this->buildToggle('background_overlay', 'Overlay oscuro sobre imagen', $bgOverlay, $componentId, 'styles', ['data-conditional-field' => 'background_type', 'data-conditional-value' => 'image']);
|
||||||
|
$overlayOpacity = $data['styles']['overlay_opacity'] ?? 60;
|
||||||
|
$html .= $this->buildNumberField('overlay_opacity', 'Opacidad del overlay (%)', $overlayOpacity, $componentId, 'styles', 0, 100, ['data-conditional-field' => 'background_overlay', 'data-conditional-value' => 'true']);
|
||||||
|
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
$html .= $this->buildColorField('text_color', 'Color del texto', $textColor, $componentId, 'styles');
|
||||||
|
$padding = $data['styles']['padding_vertical'] ?? 'normal';
|
||||||
|
$html .= $this->buildSelect('padding_vertical', 'Padding vertical', $padding, ['compact' => 'Compacto (2rem)', 'normal' => 'Normal (3rem)', 'spacious' => 'Espacioso (4rem)', 'extra-spacious' => 'Extra espacioso (5rem)'], $componentId, 'styles');
|
||||||
|
$margin = $data['styles']['margin_bottom'] ?? 'normal';
|
||||||
|
$html .= $this->buildSelect('margin_bottom', 'Margen inferior', $margin, ['none' => 'Sin margen', 'small' => 'Pequeño (1rem)', 'normal' => 'Normal (1.5rem)', 'large' => 'Grande (2rem)'], $componentId, 'styles');
|
||||||
|
$badgeBg = $data['styles']['category_badge_background'] ?? 'rgba(255, 255, 255, 0.2)';
|
||||||
|
$html .= $this->buildTextField('category_badge_background', 'Fondo de badges', $badgeBg, $componentId, 'styles');
|
||||||
|
$badgeText = $data['styles']['category_badge_text_color'] ?? '#FFFFFF';
|
||||||
|
$html .= $this->buildColorField('category_badge_text_color', 'Color del texto de badges', $badgeText, $componentId, 'styles');
|
||||||
|
$badgeBlur = $data['styles']['category_badge_blur'] ?? true;
|
||||||
|
$html .= $this->buildToggle('category_badge_blur', 'Efecto blur en badges', $badgeBlur, $componentId, 'styles');
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$checked = $value ? 'checked' : '';
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
return sprintf('<div class="roi-form-field mb-3"><div class="form-check form-switch"><input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s][%s]" value="1" %s%s><label class="form-check-label" for="%s">%s</label></div></div>', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $checked, $attrString, esc_attr($fieldId), esc_html($label));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTextField(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" placeholder="%s"%s></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), esc_attr($placeholder), $attrString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', int $rows = 3, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><textarea class="form-control" id="%s" name="roi_component[%s][%s][%s]" rows="%d" placeholder="%s"%s>%s</textarea></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $rows, esc_attr($placeholder), $attrString, esc_textarea($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
$html = sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= sprintf('<select class="form-select" id="%s" name="roi_component[%s][%s][%s]"%s>', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), $attrString);
|
||||||
|
foreach ($options as $optValue => $optLabel) {
|
||||||
|
$selected = ($value === $optValue) ? 'selected' : '';
|
||||||
|
$html .= sprintf('<option value="%s" %s>%s</option>', esc_attr($optValue), $selected, esc_html($optLabel));
|
||||||
|
}
|
||||||
|
$html .= '</select></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNumberField(string $name, string $label, $value, string $componentId, string $group, int $min = null, int $max = null, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrs['type'] = 'number';
|
||||||
|
if ($min !== null) $attrs['min'] = $min;
|
||||||
|
if ($max !== null) $attrs['max'] = $max;
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><input class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s"%s></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), $attrString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildColorField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
return sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label><div class="input-group"><input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s][%s]" value="%s"><input type="text" class="form-control" value="%s" readonly></div></div>', esc_attr($fieldId), esc_html($label), esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), esc_attr($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
$html = sprintf('<div class="roi-form-field mb-3"><label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '<div class="input-group">';
|
||||||
|
$html .= sprintf('<input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" readonly%s>', esc_attr($fieldId), esc_attr($componentId), esc_attr($group), esc_attr($name), esc_attr($value), $attrString);
|
||||||
|
$html .= sprintf('<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>', esc_attr($fieldId));
|
||||||
|
$html .= '</div>';
|
||||||
|
if (!empty($value)) {
|
||||||
|
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 300px; height: auto;"></div>', esc_url($value));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAttributesString(array $attrs): string
|
||||||
|
{
|
||||||
|
$attrString = '';
|
||||||
|
foreach ($attrs as $key => $value) {
|
||||||
|
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
|
||||||
|
}
|
||||||
|
return $attrString;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFormScripts(string $componentId): string
|
||||||
|
{
|
||||||
|
return <<<SCRIPT
|
||||||
|
<script>
|
||||||
|
(function($) {
|
||||||
|
'use strict';
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('[data-conditional-field]').each(function() {
|
||||||
|
const field = $(this);
|
||||||
|
const targetFieldName = field.data('conditional-field');
|
||||||
|
const targetValue = field.data('conditional-value');
|
||||||
|
const targetField = $('[name*="[' + targetFieldName + ']"]');
|
||||||
|
function updateVisibility() {
|
||||||
|
let currentValue = targetField.is(':checkbox') ? (targetField.is(':checked') ? 'true' : 'false') : targetField.val();
|
||||||
|
field.closest('.roi-form-field')[currentValue === targetValue ? 'show' : 'hide']();
|
||||||
|
}
|
||||||
|
targetField.on('change', updateVisibility);
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
$('.roi-media-upload-btn').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const button = $(this);
|
||||||
|
const targetId = button.data('target');
|
||||||
|
const targetField = $('#' + targetId);
|
||||||
|
const mediaUploader = wp.media({title: 'Seleccionar imagen', button: {text: 'Usar esta imagen'}, multiple: false});
|
||||||
|
mediaUploader.on('select', function() {
|
||||||
|
const attachment = mediaUploader.state().get('selection').first().toJSON();
|
||||||
|
targetField.val(attachment.url);
|
||||||
|
const preview = targetField.closest('.roi-form-field').find('img');
|
||||||
|
if (preview.length) {
|
||||||
|
preview.attr('src', attachment.url);
|
||||||
|
} else {
|
||||||
|
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 300px; height: auto;"></div>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mediaUploader.open();
|
||||||
|
});
|
||||||
|
$('.form-control-color').on('change', function() {
|
||||||
|
$(this).next('input[type="text"]').val($(this).val());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(jQuery);
|
||||||
|
</script>
|
||||||
|
SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\HeroSection\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class HeroSectionRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = $this->buildSectionClasses($data);
|
||||||
|
$styles = $this->buildInlineStyles($data);
|
||||||
|
|
||||||
|
$html = sprintf(
|
||||||
|
'<div class="%s"%s>',
|
||||||
|
esc_attr($classes),
|
||||||
|
$styles ? ' style="' . esc_attr($styles) . '"' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '<div class="container">';
|
||||||
|
|
||||||
|
// Categories badges
|
||||||
|
if ($this->shouldShowCategories($data)) {
|
||||||
|
$html .= $this->buildCategoriesBadges($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
$html .= $this->buildTitle($data);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
// Custom styles
|
||||||
|
$html .= $this->buildCustomStyles($data);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowOnCurrentPage(array $data): bool
|
||||||
|
{
|
||||||
|
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||||
|
|
||||||
|
switch ($showOn) {
|
||||||
|
case 'all':
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'home':
|
||||||
|
return is_front_page();
|
||||||
|
|
||||||
|
case 'posts':
|
||||||
|
return is_single() && get_post_type() === 'post';
|
||||||
|
|
||||||
|
case 'pages':
|
||||||
|
return is_page();
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
$postTypes = $data['visibility']['custom_post_types'] ?? '';
|
||||||
|
$allowedTypes = array_map('trim', explode(',', $postTypes));
|
||||||
|
return in_array(get_post_type(), $allowedTypes, true);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowCategories(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['categories']['show_categories']) &&
|
||||||
|
$data['categories']['show_categories'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSectionClasses(array $data): string
|
||||||
|
{
|
||||||
|
$classes = ['container-fluid', 'hero-title'];
|
||||||
|
|
||||||
|
$paddingClass = $this->getPaddingClass($data['styles']['padding_vertical'] ?? 'normal');
|
||||||
|
$classes[] = $paddingClass;
|
||||||
|
|
||||||
|
$marginClass = $this->getMarginClass($data['styles']['margin_bottom'] ?? 'normal');
|
||||||
|
if ($marginClass) {
|
||||||
|
$classes[] = $marginClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPaddingClass(string $padding): string
|
||||||
|
{
|
||||||
|
$paddings = [
|
||||||
|
'compact' => 'py-3',
|
||||||
|
'normal' => 'py-5',
|
||||||
|
'spacious' => 'py-6',
|
||||||
|
'extra-spacious' => 'py-7'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $paddings[$padding] ?? 'py-5';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMarginClass(string $margin): string
|
||||||
|
{
|
||||||
|
$margins = [
|
||||||
|
'none' => '',
|
||||||
|
'small' => 'mb-2',
|
||||||
|
'normal' => 'mb-4',
|
||||||
|
'large' => 'mb-5'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $margins[$margin] ?? 'mb-4';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInlineStyles(array $data): string
|
||||||
|
{
|
||||||
|
$styles = [];
|
||||||
|
$backgroundType = $data['styles']['background_type'] ?? 'gradient';
|
||||||
|
|
||||||
|
switch ($backgroundType) {
|
||||||
|
case 'color':
|
||||||
|
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
|
||||||
|
$styles[] = "background-color: {$bgColor}";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gradient':
|
||||||
|
$startColor = $data['styles']['gradient_start_color'] ?? '#1e3a5f';
|
||||||
|
$endColor = $data['styles']['gradient_end_color'] ?? '#2c5282';
|
||||||
|
$angle = $data['styles']['gradient_angle'] ?? 135;
|
||||||
|
$styles[] = "background: linear-gradient({$angle}deg, {$startColor}, {$endColor})";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
$imageUrl = $data['styles']['background_image_url'] ?? '';
|
||||||
|
if (!empty($imageUrl)) {
|
||||||
|
$styles[] = "background-image: url('" . esc_url($imageUrl) . "')";
|
||||||
|
$styles[] = "background-size: cover";
|
||||||
|
$styles[] = "background-position: center";
|
||||||
|
$styles[] = "background-repeat: no-repeat";
|
||||||
|
|
||||||
|
if (isset($data['styles']['background_overlay']) && $data['styles']['background_overlay']) {
|
||||||
|
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
|
||||||
|
$styles[] = "position: relative";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text color
|
||||||
|
if (!empty($data['styles']['text_color'])) {
|
||||||
|
$styles[] = 'color: ' . $data['styles']['text_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCategoriesBadges(array $data): string
|
||||||
|
{
|
||||||
|
$categories = $this->getCategories($data);
|
||||||
|
|
||||||
|
if (empty($categories)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxCategories = $data['categories']['max_categories'] ?? 5;
|
||||||
|
$categories = array_slice($categories, 0, $maxCategories);
|
||||||
|
|
||||||
|
$alignment = $data['categories']['categories_alignment'] ?? 'center';
|
||||||
|
$alignmentClasses = [
|
||||||
|
'left' => 'justify-content-start',
|
||||||
|
'center' => 'justify-content-center',
|
||||||
|
'right' => 'justify-content-end'
|
||||||
|
];
|
||||||
|
$alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center';
|
||||||
|
|
||||||
|
$icon = $data['categories']['category_icon'] ?? 'bi-folder-fill';
|
||||||
|
if (strpos($icon, 'bi-') !== 0) {
|
||||||
|
$icon = 'bi-' . $icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = sprintf('<div class="mb-3 d-flex %s">', esc_attr($alignmentClass));
|
||||||
|
$html .= '<div class="d-flex gap-2 flex-wrap justify-content-center">';
|
||||||
|
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$html .= sprintf(
|
||||||
|
'<a href="%s" class="category-badge category-badge-hero"><i class="bi %s me-1"></i>%s</a>',
|
||||||
|
esc_url($category['url']),
|
||||||
|
esc_attr($icon),
|
||||||
|
esc_html($category['name'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCategories(array $data): array
|
||||||
|
{
|
||||||
|
$source = $data['categories']['categories_source'] ?? 'post_categories';
|
||||||
|
|
||||||
|
switch ($source) {
|
||||||
|
case 'post_categories':
|
||||||
|
return $this->getPostCategories();
|
||||||
|
|
||||||
|
case 'post_tags':
|
||||||
|
return $this->getPostTags();
|
||||||
|
|
||||||
|
case 'custom_taxonomy':
|
||||||
|
$taxonomy = $data['categories']['custom_taxonomy_name'] ?? '';
|
||||||
|
return $this->getCustomTaxonomyTerms($taxonomy);
|
||||||
|
|
||||||
|
case 'custom_list':
|
||||||
|
$list = $data['categories']['custom_categories_list'] ?? '';
|
||||||
|
return $this->parseCustomCategoriesList($list);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPostCategories(): array
|
||||||
|
{
|
||||||
|
$categories = get_the_category();
|
||||||
|
if (empty($categories)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$result[] = [
|
||||||
|
'name' => $category->name,
|
||||||
|
'url' => get_category_link($category->term_id)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPostTags(): array
|
||||||
|
{
|
||||||
|
$tags = get_the_tags();
|
||||||
|
if (empty($tags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$result[] = [
|
||||||
|
'name' => $tag->name,
|
||||||
|
'url' => get_tag_link($tag->term_id)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCustomTaxonomyTerms(string $taxonomy): array
|
||||||
|
{
|
||||||
|
if (empty($taxonomy)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$terms = get_the_terms(get_the_ID(), $taxonomy);
|
||||||
|
if (empty($terms) || is_wp_error($terms)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
$result[] = [
|
||||||
|
'name' => $term->name,
|
||||||
|
'url' => get_term_link($term)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseCustomCategoriesList(string $list): array
|
||||||
|
{
|
||||||
|
if (empty($list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = explode("\n", $list);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('|', $line);
|
||||||
|
if (count($parts) >= 2) {
|
||||||
|
$result[] = [
|
||||||
|
'name' => trim($parts[0]),
|
||||||
|
'url' => trim($parts[1])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTitle(array $data): string
|
||||||
|
{
|
||||||
|
$titleText = $this->getTitleText($data);
|
||||||
|
|
||||||
|
if (empty($titleText)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$titleTag = $data['title']['title_tag'] ?? 'h1';
|
||||||
|
$titleClasses = $data['title']['title_classes'] ?? 'display-5 fw-bold';
|
||||||
|
$alignment = $data['title']['title_alignment'] ?? 'center';
|
||||||
|
|
||||||
|
$alignmentClasses = [
|
||||||
|
'left' => 'text-start',
|
||||||
|
'center' => 'text-center',
|
||||||
|
'right' => 'text-end'
|
||||||
|
];
|
||||||
|
$alignmentClass = $alignmentClasses[$alignment] ?? 'text-center';
|
||||||
|
|
||||||
|
$classes = trim($titleClasses . ' ' . $alignmentClass);
|
||||||
|
|
||||||
|
$titleStyle = '';
|
||||||
|
if (isset($data['title']['enable_gradient']) && $data['title']['enable_gradient']) {
|
||||||
|
$titleStyle = $this->buildGradientStyle($data);
|
||||||
|
$classes .= ' roi-gradient-text';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<%s class="%s"%s>%s</%s>',
|
||||||
|
esc_attr($titleTag),
|
||||||
|
esc_attr($classes),
|
||||||
|
$titleStyle ? ' style="' . esc_attr($titleStyle) . '"' : '',
|
||||||
|
esc_html($titleText),
|
||||||
|
esc_attr($titleTag)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTitleText(array $data): string
|
||||||
|
{
|
||||||
|
$source = $data['title']['title_source'] ?? 'post_title';
|
||||||
|
|
||||||
|
switch ($source) {
|
||||||
|
case 'post_title':
|
||||||
|
return get_the_title();
|
||||||
|
|
||||||
|
case 'custom_field':
|
||||||
|
$fieldName = $data['title']['custom_field_name'] ?? '';
|
||||||
|
if (!empty($fieldName)) {
|
||||||
|
$value = get_post_meta(get_the_ID(), $fieldName, true);
|
||||||
|
return is_string($value) ? $value : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
|
||||||
|
case 'custom_text':
|
||||||
|
return $data['title']['custom_text'] ?? '';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return get_the_title();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildGradientStyle(array $data): string
|
||||||
|
{
|
||||||
|
$startColor = $data['title']['gradient_color_start'] ?? '#1e3a5f';
|
||||||
|
$endColor = $data['title']['gradient_color_end'] ?? '#FF8600';
|
||||||
|
$direction = $data['title']['gradient_direction'] ?? 'to-right';
|
||||||
|
|
||||||
|
$directions = [
|
||||||
|
'to-right' => 'to right',
|
||||||
|
'to-left' => 'to left',
|
||||||
|
'to-bottom' => 'to bottom',
|
||||||
|
'to-top' => 'to top',
|
||||||
|
'diagonal' => '135deg'
|
||||||
|
];
|
||||||
|
|
||||||
|
$gradientDirection = $directions[$direction] ?? 'to right';
|
||||||
|
|
||||||
|
return "background: linear-gradient({$gradientDirection}, {$startColor}, {$endColor}); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCustomStyles(array $data): string
|
||||||
|
{
|
||||||
|
$badgeBg = $data['styles']['category_badge_background'] ?? 'rgba(255, 255, 255, 0.2)';
|
||||||
|
$badgeTextColor = $data['styles']['category_badge_text_color'] ?? '#FFFFFF';
|
||||||
|
$badgeBlur = isset($data['styles']['category_badge_blur']) && $data['styles']['category_badge_blur'];
|
||||||
|
|
||||||
|
$blurStyle = $badgeBlur ? 'backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);' : '';
|
||||||
|
|
||||||
|
$overlayStyle = '';
|
||||||
|
if (($data['styles']['background_type'] ?? '') === 'image' &&
|
||||||
|
isset($data['styles']['background_overlay']) &&
|
||||||
|
$data['styles']['background_overlay']) {
|
||||||
|
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
|
||||||
|
$overlayStyle = <<<CSS
|
||||||
|
.hero-title::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, {$opacity});
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.hero-title > .container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
CSS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <<<STYLES
|
||||||
|
<style>
|
||||||
|
.category-badge-hero {
|
||||||
|
background-color: {$badgeBg};
|
||||||
|
color: {$badgeTextColor};
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
{$blurStyle}
|
||||||
|
}
|
||||||
|
.category-badge-hero:hover {
|
||||||
|
background-color: rgba(255, 134, 0, 0.3);
|
||||||
|
color: {$badgeTextColor};
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.roi-gradient-text {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
{$overlayStyle}
|
||||||
|
</style>
|
||||||
|
STYLES;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'hero-section';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Navbar\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class NavbarFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
$data = $this->data;
|
||||||
|
$componentId = $this->componentId;
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-builder roi-navbar-form">';
|
||||||
|
|
||||||
|
// Tabs navigation
|
||||||
|
$html .= $this->buildTabsNavigation();
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
$html .= '<div class="tab-content mt-3">';
|
||||||
|
|
||||||
|
// Visibility tab
|
||||||
|
$html .= $this->buildVisibilityTab($data, $componentId);
|
||||||
|
|
||||||
|
// Logo tab
|
||||||
|
$html .= $this->buildLogoTab($data, $componentId);
|
||||||
|
|
||||||
|
// Menu tab
|
||||||
|
$html .= $this->buildMenuTab($data, $componentId);
|
||||||
|
|
||||||
|
// CTA Button tab
|
||||||
|
$html .= $this->buildCtaButtonTab($data, $componentId);
|
||||||
|
|
||||||
|
// Styles tab
|
||||||
|
$html .= $this->buildStylesTab($data, $componentId);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= $this->buildFormScripts($componentId);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTabsNavigation(): string
|
||||||
|
{
|
||||||
|
return <<<HTML
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logo-tab" type="button">Logo</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#menu-tab" type="button">Menú</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#cta-tab" type="button">Botón CTA</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilityTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade show active" id="visibility-tab">';
|
||||||
|
$html .= '<div class="p-3">';
|
||||||
|
|
||||||
|
$isEnabled = $data['visibility']['is_enabled'] ?? true;
|
||||||
|
$html .= $this->buildToggle('is_enabled', 'Mostrar navbar', $isEnabled, $componentId, 'visibility');
|
||||||
|
|
||||||
|
$isSticky = $data['visibility']['is_sticky'] ?? true;
|
||||||
|
$html .= $this->buildToggle('is_sticky', 'Navbar fijo (sticky)', $isSticky, $componentId, 'visibility');
|
||||||
|
|
||||||
|
$hideOnScroll = $data['visibility']['hide_on_scroll'] ?? false;
|
||||||
|
$html .= $this->buildToggle('hide_on_scroll', 'Ocultar al hacer scroll hacia abajo', $hideOnScroll, $componentId, 'visibility');
|
||||||
|
|
||||||
|
$showOnMobile = $data['visibility']['show_on_mobile'] ?? true;
|
||||||
|
$html .= $this->buildToggle('show_on_mobile', 'Mostrar en dispositivos móviles', $showOnMobile, $componentId, 'visibility');
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLogoTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="logo-tab">';
|
||||||
|
$html .= '<div class="p-3">';
|
||||||
|
|
||||||
|
$logoType = $data['logo']['logo_type'] ?? 'image';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'logo_type',
|
||||||
|
'Tipo de logo',
|
||||||
|
$logoType,
|
||||||
|
['image' => 'Imagen', 'text' => 'Texto', 'none' => 'Sin logo'],
|
||||||
|
$componentId,
|
||||||
|
'logo'
|
||||||
|
);
|
||||||
|
|
||||||
|
$logoImageUrl = $data['logo']['logo_image_url'] ?? '';
|
||||||
|
$html .= $this->buildMediaField(
|
||||||
|
'logo_image_url',
|
||||||
|
'Imagen del logo',
|
||||||
|
$logoImageUrl,
|
||||||
|
$componentId,
|
||||||
|
'logo',
|
||||||
|
['data-conditional-field' => 'logo_type', 'data-conditional-value' => 'image']
|
||||||
|
);
|
||||||
|
|
||||||
|
$logoWidth = $data['logo']['logo_image_width'] ?? 150;
|
||||||
|
$html .= $this->buildNumberField(
|
||||||
|
'logo_image_width',
|
||||||
|
'Ancho del logo (px)',
|
||||||
|
$logoWidth,
|
||||||
|
$componentId,
|
||||||
|
'logo',
|
||||||
|
50,
|
||||||
|
400,
|
||||||
|
['data-conditional-field' => 'logo_type', 'data-conditional-value' => 'image']
|
||||||
|
);
|
||||||
|
|
||||||
|
$logoText = $data['logo']['logo_text'] ?? '';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'logo_text',
|
||||||
|
'Texto del logo',
|
||||||
|
$logoText,
|
||||||
|
$componentId,
|
||||||
|
'logo',
|
||||||
|
'',
|
||||||
|
['data-conditional-field' => 'logo_type', 'data-conditional-value' => 'text']
|
||||||
|
);
|
||||||
|
|
||||||
|
$logoLink = $data['logo']['logo_link'] ?? '';
|
||||||
|
$html .= $this->buildUrlField(
|
||||||
|
'logo_link',
|
||||||
|
'Enlace del logo',
|
||||||
|
$logoLink,
|
||||||
|
$componentId,
|
||||||
|
'logo',
|
||||||
|
'Dejar vacío para usar la URL del home'
|
||||||
|
);
|
||||||
|
|
||||||
|
$logoPosition = $data['logo']['logo_position'] ?? 'left';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'logo_position',
|
||||||
|
'Posición del logo',
|
||||||
|
$logoPosition,
|
||||||
|
['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'],
|
||||||
|
$componentId,
|
||||||
|
'logo'
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMenuTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="menu-tab">';
|
||||||
|
$html .= '<div class="p-3">';
|
||||||
|
|
||||||
|
$menuLocation = $data['menu']['menu_location'] ?? 'primary';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'menu_location',
|
||||||
|
'Ubicación del menú',
|
||||||
|
$menuLocation,
|
||||||
|
['primary' => 'Menú Principal', 'secondary' => 'Menú Secundario', 'custom' => 'Menú personalizado'],
|
||||||
|
$componentId,
|
||||||
|
'menu'
|
||||||
|
);
|
||||||
|
|
||||||
|
$customMenuId = $data['menu']['custom_menu_id'] ?? 0;
|
||||||
|
$html .= $this->buildNumberField(
|
||||||
|
'custom_menu_id',
|
||||||
|
'ID del menú personalizado',
|
||||||
|
$customMenuId,
|
||||||
|
$componentId,
|
||||||
|
'menu',
|
||||||
|
0,
|
||||||
|
99999,
|
||||||
|
['data-conditional-field' => 'menu_location', 'data-conditional-value' => 'custom']
|
||||||
|
);
|
||||||
|
|
||||||
|
$menuAlignment = $data['menu']['menu_alignment'] ?? 'left';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'menu_alignment',
|
||||||
|
'Alineación del menú',
|
||||||
|
$menuAlignment,
|
||||||
|
['left' => 'Izquierda', 'center' => 'Centro', 'right' => 'Derecha'],
|
||||||
|
$componentId,
|
||||||
|
'menu'
|
||||||
|
);
|
||||||
|
|
||||||
|
$enableDropdowns = $data['menu']['enable_dropdowns'] ?? true;
|
||||||
|
$html .= $this->buildToggle('enable_dropdowns', 'Habilitar menús desplegables', $enableDropdowns, $componentId, 'menu');
|
||||||
|
|
||||||
|
$dropdownAnimation = $data['menu']['dropdown_animation'] ?? 'fade';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'dropdown_animation',
|
||||||
|
'Animación de dropdowns',
|
||||||
|
$dropdownAnimation,
|
||||||
|
['none' => 'Sin animación', 'fade' => 'Aparecer gradualmente', 'slide' => 'Deslizar'],
|
||||||
|
$componentId,
|
||||||
|
'menu',
|
||||||
|
['data-conditional-field' => 'enable_dropdowns', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$mobileBreakpoint = $data['menu']['mobile_breakpoint'] ?? 'lg';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'mobile_breakpoint',
|
||||||
|
'Breakpoint para menú móvil',
|
||||||
|
$mobileBreakpoint,
|
||||||
|
['sm' => 'Small (576px)', 'md' => 'Medium (768px)', 'lg' => 'Large (992px)', 'xl' => 'Extra Large (1200px)'],
|
||||||
|
$componentId,
|
||||||
|
'menu'
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCtaButtonTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="cta-tab">';
|
||||||
|
$html .= '<div class="p-3">';
|
||||||
|
|
||||||
|
$buttonEnabled = $data['cta_button']['button_enabled'] ?? true;
|
||||||
|
$html .= $this->buildToggle('button_enabled', 'Mostrar botón CTA', $buttonEnabled, $componentId, 'cta_button');
|
||||||
|
|
||||||
|
$buttonText = $data['cta_button']['button_text'] ?? 'Let\'s Talk';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'button_text',
|
||||||
|
'Texto del botón',
|
||||||
|
$buttonText,
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
'',
|
||||||
|
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$buttonIcon = $data['cta_button']['button_icon'] ?? 'bi-lightning-charge-fill';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'button_icon',
|
||||||
|
'Ícono del botón',
|
||||||
|
$buttonIcon,
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
'Ej: bi-lightning-charge-fill',
|
||||||
|
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$actionType = $data['cta_button']['button_action_type'] ?? 'modal';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'button_action_type',
|
||||||
|
'Tipo de acción del botón',
|
||||||
|
$actionType,
|
||||||
|
['modal' => 'Abrir modal', 'link' => 'Ir a URL', 'scroll' => 'Scroll a sección'],
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$modalTarget = $data['cta_button']['button_modal_target'] ?? '#contactModal';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'button_modal_target',
|
||||||
|
'ID del modal',
|
||||||
|
$modalTarget,
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
'#contactModal',
|
||||||
|
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'modal']
|
||||||
|
);
|
||||||
|
|
||||||
|
$linkUrl = $data['cta_button']['button_link_url'] ?? '';
|
||||||
|
$html .= $this->buildUrlField(
|
||||||
|
'button_link_url',
|
||||||
|
'URL del enlace',
|
||||||
|
$linkUrl,
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
'https://',
|
||||||
|
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'link']
|
||||||
|
);
|
||||||
|
|
||||||
|
$linkTarget = $data['cta_button']['button_link_target'] ?? '_self';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'button_link_target',
|
||||||
|
'Abrir enlace en',
|
||||||
|
$linkTarget,
|
||||||
|
['_self' => 'Misma ventana', '_blank' => 'Nueva ventana'],
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'link']
|
||||||
|
);
|
||||||
|
|
||||||
|
$scrollTarget = $data['cta_button']['button_scroll_target'] ?? '';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'button_scroll_target',
|
||||||
|
'ID de la sección',
|
||||||
|
$scrollTarget,
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
'#contact',
|
||||||
|
['data-conditional-field' => 'button_action_type', 'data-conditional-value' => 'scroll']
|
||||||
|
);
|
||||||
|
|
||||||
|
$buttonPosition = $data['cta_button']['button_position'] ?? 'right';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'button_position',
|
||||||
|
'Posición del botón',
|
||||||
|
$buttonPosition,
|
||||||
|
['left' => 'Antes del menú', 'right' => 'Después del menú'],
|
||||||
|
$componentId,
|
||||||
|
'cta_button',
|
||||||
|
['data-conditional-field' => 'button_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="tab-pane fade" id="styles-tab">';
|
||||||
|
$html .= '<div class="p-3">';
|
||||||
|
|
||||||
|
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
|
||||||
|
$html .= $this->buildColorField('background_color', 'Color de fondo', $bgColor, $componentId, 'styles');
|
||||||
|
|
||||||
|
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
$html .= $this->buildColorField('text_color', 'Color del texto', $textColor, $componentId, 'styles');
|
||||||
|
|
||||||
|
$hoverColor = $data['styles']['hover_color'] ?? '#FF8600';
|
||||||
|
$html .= $this->buildColorField('hover_color', 'Color hover', $hoverColor, $componentId, 'styles');
|
||||||
|
|
||||||
|
$activeColor = $data['styles']['active_color'] ?? '#FF8600';
|
||||||
|
$html .= $this->buildColorField('active_color', 'Color del item activo', $activeColor, $componentId, 'styles');
|
||||||
|
|
||||||
|
$buttonBg = $data['styles']['button_background'] ?? '#FF8600';
|
||||||
|
$html .= $this->buildColorField('button_background', 'Color de fondo del botón', $buttonBg, $componentId, 'styles');
|
||||||
|
|
||||||
|
$buttonTextColor = $data['styles']['button_text_color'] ?? '#FFFFFF';
|
||||||
|
$html .= $this->buildColorField('button_text_color', 'Color del texto del botón', $buttonTextColor, $componentId, 'styles');
|
||||||
|
|
||||||
|
$buttonHoverBg = $data['styles']['button_hover_background'] ?? '#FF6B35';
|
||||||
|
$html .= $this->buildColorField('button_hover_background', 'Color hover del botón', $buttonHoverBg, $componentId, 'styles');
|
||||||
|
|
||||||
|
$padding = $data['styles']['padding_vertical'] ?? 'normal';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'padding_vertical',
|
||||||
|
'Padding vertical',
|
||||||
|
$padding,
|
||||||
|
['compact' => 'Compacto (0.5rem)', 'normal' => 'Normal (1rem)', 'spacious' => 'Espacioso (1.5rem)'],
|
||||||
|
$componentId,
|
||||||
|
'styles'
|
||||||
|
);
|
||||||
|
|
||||||
|
$shadowEnabled = $data['styles']['shadow_enabled'] ?? true;
|
||||||
|
$html .= $this->buildToggle('shadow_enabled', 'Activar sombra', $shadowEnabled, $componentId, 'styles');
|
||||||
|
|
||||||
|
$shadowIntensity = $data['styles']['shadow_intensity'] ?? 'medium';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'shadow_intensity',
|
||||||
|
'Intensidad de la sombra',
|
||||||
|
$shadowIntensity,
|
||||||
|
['light' => 'Ligera', 'medium' => 'Media', 'strong' => 'Fuerte'],
|
||||||
|
$componentId,
|
||||||
|
'styles',
|
||||||
|
['data-conditional-field' => 'shadow_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$borderEnabled = $data['styles']['border_bottom_enabled'] ?? false;
|
||||||
|
$html .= $this->buildToggle('border_bottom_enabled', 'Borde inferior', $borderEnabled, $componentId, 'styles');
|
||||||
|
|
||||||
|
$borderColor = $data['styles']['border_bottom_color'] ?? '#FF8600';
|
||||||
|
$html .= $this->buildColorField(
|
||||||
|
'border_bottom_color',
|
||||||
|
'Color del borde inferior',
|
||||||
|
$borderColor,
|
||||||
|
$componentId,
|
||||||
|
'styles',
|
||||||
|
['data-conditional-field' => 'border_bottom_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$borderWidth = $data['styles']['border_bottom_width'] ?? 3;
|
||||||
|
$html .= $this->buildNumberField(
|
||||||
|
'border_bottom_width',
|
||||||
|
'Grosor del borde (px)',
|
||||||
|
$borderWidth,
|
||||||
|
$componentId,
|
||||||
|
'styles',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
['data-conditional-field' => 'border_bottom_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods (similar to TopNotificationBarFormBuilder)
|
||||||
|
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $group): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$checked = $value ? 'checked' : '';
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field mb-3">';
|
||||||
|
$html .= '<div class="form-check form-switch">';
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s][%s]" value="1" %s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($group),
|
||||||
|
esc_attr($name),
|
||||||
|
$checked
|
||||||
|
);
|
||||||
|
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTextField(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" placeholder="%s"%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($group),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value),
|
||||||
|
esc_attr($placeholder),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= sprintf(
|
||||||
|
'<select class="form-select" id="%s" name="roi_component[%s][%s][%s]"%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($group),
|
||||||
|
esc_attr($name),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($options as $optValue => $optLabel) {
|
||||||
|
$selected = ($value === $optValue) ? 'selected' : '';
|
||||||
|
$html .= sprintf('<option value="%s" %s>%s</option>', esc_attr($optValue), $selected, esc_html($optLabel));
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</select>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNumberField(string $name, string $label, $value, string $componentId, string $group, int $min = null, int $max = null, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
|
||||||
|
$attrs['type'] = 'number';
|
||||||
|
if ($min !== null) $attrs['min'] = $min;
|
||||||
|
if ($max !== null) $attrs['max'] = $max;
|
||||||
|
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s"%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($group),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $group, string $placeholder = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$attrs['type'] = 'url';
|
||||||
|
return $this->buildTextField($name, $label, $value, $componentId, $group, $placeholder, $attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildColorField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '<div class="input-group">';
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s][%s]" value="%s">',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($group),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value)
|
||||||
|
);
|
||||||
|
$html .= sprintf('<input type="text" class="form-control" value="%s" readonly>', esc_attr($value));
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $group, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$group}_{$name}";
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '<div class="input-group">';
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s][%s]" value="%s" readonly%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($group),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
$html .= sprintf('<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>', esc_attr($fieldId));
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
if (!empty($value)) {
|
||||||
|
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 200px; height: auto;"></div>', esc_url($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAttributesString(array $attrs): string
|
||||||
|
{
|
||||||
|
$attrString = '';
|
||||||
|
foreach ($attrs as $key => $value) {
|
||||||
|
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
|
||||||
|
}
|
||||||
|
return $attrString;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFormScripts(string $componentId): string
|
||||||
|
{
|
||||||
|
return <<<SCRIPT
|
||||||
|
<script>
|
||||||
|
(function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Conditional logic handler
|
||||||
|
$('[data-conditional-field]').each(function() {
|
||||||
|
const field = $(this);
|
||||||
|
const targetFieldName = field.data('conditional-field');
|
||||||
|
const targetValue = field.data('conditional-value');
|
||||||
|
const targetField = $('[name*="[' + targetFieldName + ']"]');
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
let currentValue;
|
||||||
|
if (targetField.is(':checkbox')) {
|
||||||
|
currentValue = targetField.is(':checked') ? 'true' : 'false';
|
||||||
|
} else {
|
||||||
|
currentValue = targetField.val();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue === targetValue) {
|
||||||
|
field.closest('.roi-form-field').show();
|
||||||
|
} else {
|
||||||
|
field.closest('.roi-form-field').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetField.on('change', updateVisibility);
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media uploader
|
||||||
|
$('.roi-media-upload-btn').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const button = $(this);
|
||||||
|
const targetId = button.data('target');
|
||||||
|
const targetField = $('#' + targetId);
|
||||||
|
|
||||||
|
const mediaUploader = wp.media({
|
||||||
|
title: 'Seleccionar imagen',
|
||||||
|
button: { text: 'Usar esta imagen' },
|
||||||
|
multiple: false
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaUploader.on('select', function() {
|
||||||
|
const attachment = mediaUploader.state().get('selection').first().toJSON();
|
||||||
|
targetField.val(attachment.url);
|
||||||
|
|
||||||
|
const preview = targetField.closest('.roi-form-field').find('img');
|
||||||
|
if (preview.length) {
|
||||||
|
preview.attr('src', attachment.url);
|
||||||
|
} else {
|
||||||
|
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 200px; height: auto;"></div>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaUploader.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color picker sync
|
||||||
|
$('.form-control-color').on('change', function() {
|
||||||
|
$(this).next('input[type="text"]').val($(this).val());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(jQuery);
|
||||||
|
</script>
|
||||||
|
SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
507
src/Navbar/Infrastructure/Presentation/Public/NavbarRenderer.php
Normal file
507
src/Navbar/Infrastructure/Presentation/Public/NavbarRenderer.php
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Navbar\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
use Walker_Nav_Menu;
|
||||||
|
|
||||||
|
final class NavbarRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = $this->buildNavbarClasses($data);
|
||||||
|
$styles = $this->buildInlineStyles($data);
|
||||||
|
$containerClasses = $this->buildContainerClasses($data);
|
||||||
|
|
||||||
|
$html = sprintf(
|
||||||
|
'<nav class="%s"%s>',
|
||||||
|
esc_attr($classes),
|
||||||
|
$styles ? ' style="' . esc_attr($styles) . '"' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= sprintf('<div class="%s">', esc_attr($containerClasses));
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
if ($this->shouldShowLogo($data)) {
|
||||||
|
$html .= $this->buildLogo($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile toggle button
|
||||||
|
if ($this->shouldShowOnMobile($data)) {
|
||||||
|
$html .= $this->buildMobileToggle($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navbar collapse
|
||||||
|
$html .= '<div class="collapse navbar-collapse" id="navbarSupportedContent">';
|
||||||
|
|
||||||
|
// Button left position
|
||||||
|
if ($this->isButtonPosition($data, 'left')) {
|
||||||
|
$html .= $this->buildCtaButton($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu
|
||||||
|
$html .= $this->buildMenu($data);
|
||||||
|
|
||||||
|
// Button right position
|
||||||
|
if ($this->isButtonPosition($data, 'right')) {
|
||||||
|
$html .= $this->buildCtaButton($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</nav>';
|
||||||
|
|
||||||
|
// Add custom styles
|
||||||
|
$html .= $this->buildCustomStyles($data);
|
||||||
|
|
||||||
|
// Add sticky/hide on scroll scripts
|
||||||
|
if ($this->needsScrollScript($data)) {
|
||||||
|
$html .= $this->buildScrollScript($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowOnMobile(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['show_on_mobile']) &&
|
||||||
|
$data['visibility']['show_on_mobile'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowLogo(array $data): bool
|
||||||
|
{
|
||||||
|
$logoType = $data['logo']['logo_type'] ?? 'none';
|
||||||
|
return $logoType !== 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isButtonPosition(array $data, string $position): bool
|
||||||
|
{
|
||||||
|
if (!isset($data['cta_button']['button_enabled']) || !$data['cta_button']['button_enabled']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buttonPosition = $data['cta_button']['button_position'] ?? 'right';
|
||||||
|
return $buttonPosition === $position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function needsScrollScript(array $data): bool
|
||||||
|
{
|
||||||
|
return (isset($data['visibility']['is_sticky']) && $data['visibility']['is_sticky']) ||
|
||||||
|
(isset($data['visibility']['hide_on_scroll']) && $data['visibility']['hide_on_scroll']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNavbarClasses(array $data): string
|
||||||
|
{
|
||||||
|
$classes = ['navbar', 'navbar-expand-' . ($data['menu']['mobile_breakpoint'] ?? 'lg'), 'navbar-dark'];
|
||||||
|
|
||||||
|
if (isset($data['visibility']['is_sticky']) && $data['visibility']['is_sticky']) {
|
||||||
|
$classes[] = 'sticky-top';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['visibility']['hide_on_scroll']) && $data['visibility']['hide_on_scroll']) {
|
||||||
|
$classes[] = 'roi-navbar-autohide';
|
||||||
|
}
|
||||||
|
|
||||||
|
$paddingClass = $this->getPaddingClass($data['styles']['padding_vertical'] ?? 'normal');
|
||||||
|
$classes[] = $paddingClass;
|
||||||
|
|
||||||
|
return implode(' ', $classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPaddingClass(string $padding): string
|
||||||
|
{
|
||||||
|
$paddings = [
|
||||||
|
'compact' => 'py-2',
|
||||||
|
'normal' => 'py-3',
|
||||||
|
'spacious' => 'py-4'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $paddings[$padding] ?? 'py-3';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContainerClasses(array $data): string
|
||||||
|
{
|
||||||
|
return 'container';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInlineStyles(array $data): string
|
||||||
|
{
|
||||||
|
$styles = [];
|
||||||
|
|
||||||
|
if (!empty($data['styles']['background_color'])) {
|
||||||
|
$styles[] = 'background-color: ' . $data['styles']['background_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['styles']['border_bottom_enabled']) && $data['styles']['border_bottom_enabled']) {
|
||||||
|
$borderColor = $data['styles']['border_bottom_color'] ?? '#FF8600';
|
||||||
|
$borderWidth = $data['styles']['border_bottom_width'] ?? 3;
|
||||||
|
$styles[] = "border-bottom: {$borderWidth}px solid {$borderColor}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLogo(array $data): string
|
||||||
|
{
|
||||||
|
$logoType = $data['logo']['logo_type'] ?? 'image';
|
||||||
|
$logoLink = $data['logo']['logo_link'] ?? home_url('/');
|
||||||
|
$logoPosition = $data['logo']['logo_position'] ?? 'left';
|
||||||
|
|
||||||
|
$logoHtml = '<a class="navbar-brand" href="' . esc_url($logoLink) . '">';
|
||||||
|
|
||||||
|
switch ($logoType) {
|
||||||
|
case 'image':
|
||||||
|
$imageUrl = $data['logo']['logo_image_url'] ?? '';
|
||||||
|
$imageWidth = $data['logo']['logo_image_width'] ?? 150;
|
||||||
|
if (!empty($imageUrl)) {
|
||||||
|
$logoHtml .= sprintf(
|
||||||
|
'<img src="%s" alt="%s" style="width: %dpx; height: auto;">',
|
||||||
|
esc_url($imageUrl),
|
||||||
|
esc_attr(get_bloginfo('name')),
|
||||||
|
(int)$imageWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
$logoText = $data['logo']['logo_text'] ?? get_bloginfo('name');
|
||||||
|
$logoHtml .= esc_html($logoText);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logoHtml .= '</a>';
|
||||||
|
|
||||||
|
if ($logoPosition === 'center') {
|
||||||
|
$logoHtml = '<div class="mx-auto">' . $logoHtml . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $logoHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMobileToggle(array $data): string
|
||||||
|
{
|
||||||
|
return '<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMenu(array $data): string
|
||||||
|
{
|
||||||
|
$menuLocation = $data['menu']['menu_location'] ?? 'primary';
|
||||||
|
$menuAlignment = $data['menu']['menu_alignment'] ?? 'left';
|
||||||
|
$enableDropdowns = $data['menu']['enable_dropdowns'] ?? true;
|
||||||
|
|
||||||
|
$alignmentClasses = [
|
||||||
|
'left' => 'me-auto',
|
||||||
|
'center' => 'mx-auto',
|
||||||
|
'right' => 'ms-auto'
|
||||||
|
];
|
||||||
|
|
||||||
|
$ulClass = 'navbar-nav mb-2 mb-lg-0 ' . ($alignmentClasses[$menuAlignment] ?? 'me-auto');
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'theme_location' => $menuLocation === 'custom' ? '' : $menuLocation,
|
||||||
|
'menu' => $menuLocation === 'custom' ? ($data['menu']['custom_menu_id'] ?? 0) : '',
|
||||||
|
'container' => false,
|
||||||
|
'menu_class' => $ulClass,
|
||||||
|
'fallback_cb' => '__return_false',
|
||||||
|
'items_wrap' => '<ul id="%1$s" class="%2$s">%3$s</ul>',
|
||||||
|
'depth' => $enableDropdowns ? 2 : 1,
|
||||||
|
'walker' => new ROI_Bootstrap_Nav_Walker()
|
||||||
|
];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
wp_nav_menu($args);
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCtaButton(array $data): string
|
||||||
|
{
|
||||||
|
if (!isset($data['cta_button']['button_enabled']) || !$data['cta_button']['button_enabled']) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$buttonText = $data['cta_button']['button_text'] ?? 'Let\'s Talk';
|
||||||
|
$buttonIcon = $data['cta_button']['button_icon'] ?? 'bi-lightning-charge-fill';
|
||||||
|
$actionType = $data['cta_button']['button_action_type'] ?? 'modal';
|
||||||
|
|
||||||
|
$buttonClass = 'btn btn-lets-talk ms-lg-3';
|
||||||
|
$buttonAttrs = [];
|
||||||
|
|
||||||
|
switch ($actionType) {
|
||||||
|
case 'modal':
|
||||||
|
$modalTarget = $data['cta_button']['button_modal_target'] ?? '#contactModal';
|
||||||
|
$buttonAttrs[] = 'data-bs-toggle="modal"';
|
||||||
|
$buttonAttrs[] = 'data-bs-target="' . esc_attr($modalTarget) . '"';
|
||||||
|
$element = 'button';
|
||||||
|
$href = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'link':
|
||||||
|
$linkUrl = $data['cta_button']['button_link_url'] ?? '#';
|
||||||
|
$linkTarget = $data['cta_button']['button_link_target'] ?? '_self';
|
||||||
|
$element = 'a';
|
||||||
|
$href = ' href="' . esc_url($linkUrl) . '" target="' . esc_attr($linkTarget) . '"';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'scroll':
|
||||||
|
$scrollTarget = $data['cta_button']['button_scroll_target'] ?? '#contact';
|
||||||
|
$element = 'a';
|
||||||
|
$href = ' href="' . esc_attr($scrollTarget) . '"';
|
||||||
|
$buttonAttrs[] = 'data-scroll="true"';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$element = 'button';
|
||||||
|
$href = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrString = !empty($buttonAttrs) ? ' ' . implode(' ', $buttonAttrs) : '';
|
||||||
|
|
||||||
|
$iconHtml = '';
|
||||||
|
if (!empty($buttonIcon)) {
|
||||||
|
if (strpos($buttonIcon, 'bi-') !== 0) {
|
||||||
|
$buttonIcon = 'bi-' . $buttonIcon;
|
||||||
|
}
|
||||||
|
$iconHtml = sprintf('<i class="bi %s me-2"></i>', esc_attr($buttonIcon));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<%s class="%s"%s%s>%s%s</%s>',
|
||||||
|
$element,
|
||||||
|
esc_attr($buttonClass),
|
||||||
|
$href,
|
||||||
|
$attrString,
|
||||||
|
$iconHtml,
|
||||||
|
esc_html($buttonText),
|
||||||
|
$element
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCustomStyles(array $data): string
|
||||||
|
{
|
||||||
|
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
$hoverColor = $data['styles']['hover_color'] ?? '#FF8600';
|
||||||
|
$activeColor = $data['styles']['active_color'] ?? '#FF8600';
|
||||||
|
$buttonBg = $data['styles']['button_background'] ?? '#FF8600';
|
||||||
|
$buttonTextColor = $data['styles']['button_text_color'] ?? '#FFFFFF';
|
||||||
|
$buttonHoverBg = $data['styles']['button_hover_background'] ?? '#FF6B35';
|
||||||
|
$backgroundColor = $data['styles']['background_color'] ?? '#1e3a5f';
|
||||||
|
|
||||||
|
$shadowStyle = '';
|
||||||
|
if (isset($data['styles']['shadow_enabled']) && $data['styles']['shadow_enabled']) {
|
||||||
|
$shadowIntensity = $data['styles']['shadow_intensity'] ?? 'medium';
|
||||||
|
$shadows = [
|
||||||
|
'light' => '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
'medium' => '0 2px 8px rgba(0,0,0,0.15)',
|
||||||
|
'strong' => '0 4px 12px rgba(0,0,0,0.25)'
|
||||||
|
];
|
||||||
|
$shadowStyle = 'box-shadow: ' . $shadows[$shadowIntensity] . ';';
|
||||||
|
}
|
||||||
|
|
||||||
|
return <<<STYLES
|
||||||
|
<style>
|
||||||
|
.navbar {
|
||||||
|
{$shadowStyle}
|
||||||
|
}
|
||||||
|
.navbar .nav-link {
|
||||||
|
color: {$textColor} !important;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
.navbar .nav-link:hover,
|
||||||
|
.navbar .nav-link:focus {
|
||||||
|
color: {$hoverColor} !important;
|
||||||
|
}
|
||||||
|
.navbar .nav-link.active,
|
||||||
|
.navbar .nav-item.current-menu-item > .nav-link {
|
||||||
|
color: {$activeColor} !important;
|
||||||
|
}
|
||||||
|
.navbar .dropdown-menu {
|
||||||
|
background-color: {$backgroundColor};
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.navbar .dropdown-item {
|
||||||
|
color: {$textColor};
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
.navbar .dropdown-item:hover,
|
||||||
|
.navbar .dropdown-item:focus {
|
||||||
|
background-color: rgba(255, 134, 0, 0.1);
|
||||||
|
color: {$hoverColor};
|
||||||
|
}
|
||||||
|
.btn-lets-talk {
|
||||||
|
background-color: {$buttonBg};
|
||||||
|
color: {$buttonTextColor};
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-lets-talk:hover,
|
||||||
|
.btn-lets-talk:focus {
|
||||||
|
background-color: {$buttonHoverBg};
|
||||||
|
color: {$buttonTextColor};
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
STYLES;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildScrollScript(array $data): string
|
||||||
|
{
|
||||||
|
$isSticky = $data['visibility']['is_sticky'] ?? false;
|
||||||
|
$hideOnScroll = $data['visibility']['hide_on_scroll'] ?? false;
|
||||||
|
|
||||||
|
if (!$isSticky && !$hideOnScroll) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$script = '<script>';
|
||||||
|
|
||||||
|
if ($hideOnScroll) {
|
||||||
|
$script .= <<<JS
|
||||||
|
(function() {
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
const navbar = document.querySelector('.roi-navbar-autohide');
|
||||||
|
|
||||||
|
if (navbar) {
|
||||||
|
window.addEventListener('scroll', function() {
|
||||||
|
let scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
|
||||||
|
if (scrollTop > lastScrollTop && scrollTop > 100) {
|
||||||
|
// Scrolling down
|
||||||
|
navbar.style.transform = 'translateY(-100%)';
|
||||||
|
navbar.style.transition = 'transform 0.3s ease';
|
||||||
|
} else {
|
||||||
|
// Scrolling up
|
||||||
|
navbar.style.transform = 'translateY(0)';
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollTop = scrollTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth scroll for scroll buttons
|
||||||
|
$script .= <<<JS
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const scrollLinks = document.querySelectorAll('a[data-scroll="true"]');
|
||||||
|
scrollLinks.forEach(function(link) {
|
||||||
|
link.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = this.getAttribute('href');
|
||||||
|
const targetElement = document.querySelector(targetId);
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
JS;
|
||||||
|
|
||||||
|
$script .= '</script>';
|
||||||
|
|
||||||
|
return $script;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'navbar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Walker for Bootstrap 5 Navigation
|
||||||
|
*/
|
||||||
|
class ROI_Bootstrap_Nav_Walker extends Walker_Nav_Menu
|
||||||
|
{
|
||||||
|
public function start_lvl(&$output, $depth = 0, $args = null)
|
||||||
|
{
|
||||||
|
$indent = str_repeat("\t", $depth);
|
||||||
|
$output .= "\n$indent<ul class=\"dropdown-menu\">\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start_el(&$output, $item, $depth = 0, $args = null, $id = 0)
|
||||||
|
{
|
||||||
|
$indent = ($depth) ? str_repeat("\t", $depth) : '';
|
||||||
|
|
||||||
|
$classes = empty($item->classes) ? [] : (array) $item->classes;
|
||||||
|
$classes[] = 'nav-item';
|
||||||
|
|
||||||
|
if ($args->walker->has_children) {
|
||||||
|
$classes[] = 'dropdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$class_names = join(' ', apply_filters('nav_menu_css_class', array_filter($classes), $item, $args, $depth));
|
||||||
|
$class_names = $class_names ? ' class="' . esc_attr($class_names) . '"' : '';
|
||||||
|
|
||||||
|
$id = apply_filters('nav_menu_item_id', 'menu-item-' . $item->ID, $item, $args, $depth);
|
||||||
|
$id = $id ? ' id="' . esc_attr($id) . '"' : '';
|
||||||
|
|
||||||
|
$output .= $indent . '<li' . $id . $class_names . '>';
|
||||||
|
|
||||||
|
$atts = [];
|
||||||
|
$atts['title'] = !empty($item->attr_title) ? $item->attr_title : '';
|
||||||
|
$atts['target'] = !empty($item->target) ? $item->target : '';
|
||||||
|
$atts['rel'] = !empty($item->xfn) ? $item->xfn : '';
|
||||||
|
$atts['href'] = !empty($item->url) ? $item->url : '';
|
||||||
|
|
||||||
|
if ($depth === 0) {
|
||||||
|
$atts['class'] = 'nav-link';
|
||||||
|
if ($args->walker->has_children) {
|
||||||
|
$atts['class'] .= ' dropdown-toggle';
|
||||||
|
$atts['data-bs-toggle'] = 'dropdown';
|
||||||
|
$atts['role'] = 'button';
|
||||||
|
$atts['aria-expanded'] = 'false';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$atts['class'] = 'dropdown-item';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('current-menu-item', $classes)) {
|
||||||
|
$atts['class'] .= ' active';
|
||||||
|
}
|
||||||
|
|
||||||
|
$atts = apply_filters('nav_menu_link_attributes', $atts, $item, $args, $depth);
|
||||||
|
|
||||||
|
$attributes = '';
|
||||||
|
foreach ($atts as $attr => $value) {
|
||||||
|
if (!empty($value)) {
|
||||||
|
$value = ('href' === $attr) ? esc_url($value) : esc_attr($value);
|
||||||
|
$attributes .= ' ' . $attr . '="' . $value . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = apply_filters('the_title', $item->title, $item->ID);
|
||||||
|
$title = apply_filters('nav_menu_item_title', $title, $item, $args, $depth);
|
||||||
|
|
||||||
|
$item_output = $args->before;
|
||||||
|
$item_output .= '<a' . $attributes . '>';
|
||||||
|
$item_output .= $args->link_before . $title . $args->link_after;
|
||||||
|
$item_output .= '</a>';
|
||||||
|
$item_output .= $args->after;
|
||||||
|
|
||||||
|
$output .= apply_filters('walker_nav_menu_start_el', $item_output, $item, $depth, $args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\RelatedPosts\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class RelatedPostsFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder related-posts-form">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#query-tab" type="button">Consulta</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#layout-tab" type="button">Diseño</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#pagination-tab" type="button">Paginación</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="query-tab"><?php echo $this->buildQueryTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="layout-tab"><?php echo $this->buildLayoutTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="pagination-tab"><?php echo $this->buildPaginationTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilityTab(): string
|
||||||
|
{
|
||||||
|
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
|
||||||
|
$sectionTitle = $this->data['visibility']['section_title'] ?? 'Descubre Más Contenido';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="is_enabled"><strong>Activar posts relacionados</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Activa o desactiva el componente de posts relacionados</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="section_title"><strong>Título de la sección</strong></label>
|
||||||
|
<input type="text" class="form-control" id="section_title" name="visibility[section_title]" value="<?php echo esc_attr($sectionTitle); ?>" maxlength="200" required>
|
||||||
|
<small class="form-text text-muted">Título que aparece antes del grid de posts</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildQueryTab(): string
|
||||||
|
{
|
||||||
|
$postsPerPage = $this->data['query']['posts_per_page'] ?? 12;
|
||||||
|
$postSelection = $this->data['query']['post_selection'] ?? 'category';
|
||||||
|
$excludeCurrent = $this->data['query']['exclude_current_post'] ?? true;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="posts_per_page"><strong>Posts por página</strong></label>
|
||||||
|
<input type="number" class="form-control" id="posts_per_page" name="query[posts_per_page]" value="<?php echo esc_attr($postsPerPage); ?>" min="1" max="100" required>
|
||||||
|
<small class="form-text text-muted">Cantidad de posts a mostrar por página</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="post_selection"><strong>Criterio de selección</strong></label>
|
||||||
|
<select class="form-select" id="post_selection" name="query[post_selection]">
|
||||||
|
<option value="category" <?php selected($postSelection, 'category'); ?>>Misma categoría</option>
|
||||||
|
<option value="tags" <?php selected($postSelection, 'tags'); ?>>Mismos tags</option>
|
||||||
|
<option value="both" <?php selected($postSelection, 'both'); ?>>Categoría y tags</option>
|
||||||
|
<option value="recent" <?php selected($postSelection, 'recent'); ?>>Más recientes</option>
|
||||||
|
<option value="random" <?php selected($postSelection, 'random'); ?>>Aleatorio</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Cómo seleccionar los posts relacionados</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="exclude_current_post" name="query[exclude_current_post]" value="1" <?php checked($excludeCurrent, true); ?>>
|
||||||
|
<label class="form-check-label" for="exclude_current_post">Excluir post actual</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Excluye el post actual de los resultados</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLayoutTab(): string
|
||||||
|
{
|
||||||
|
$columns = $this->data['layout']['columns'] ?? '3';
|
||||||
|
$cardHeight = $this->data['layout']['card_height'] ?? 'equal';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="columns"><strong>Número de columnas</strong></label>
|
||||||
|
<select class="form-select" id="columns" name="layout[columns]">
|
||||||
|
<option value="1" <?php selected($columns, '1'); ?>>1 columna</option>
|
||||||
|
<option value="2" <?php selected($columns, '2'); ?>>2 columnas</option>
|
||||||
|
<option value="3" <?php selected($columns, '3'); ?>>3 columnas</option>
|
||||||
|
<option value="4" <?php selected($columns, '4'); ?>>4 columnas</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Número de columnas en el grid</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="card_height"><strong>Altura de cards</strong></label>
|
||||||
|
<select class="form-select" id="card_height" name="layout[card_height]">
|
||||||
|
<option value="auto" <?php selected($cardHeight, 'auto'); ?>>Automática</option>
|
||||||
|
<option value="equal" <?php selected($cardHeight, 'equal'); ?>>Igual (centrado)</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Controla cómo se muestran las cards</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPaginationTab(): string
|
||||||
|
{
|
||||||
|
$showPagination = $this->data['pagination']['show_pagination'] ?? true;
|
||||||
|
$paginationPosition = $this->data['pagination']['pagination_position'] ?? 'center';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_pagination" name="pagination[show_pagination]" value="1" <?php checked($showPagination, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_pagination"><strong>Mostrar paginación</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Muestra u oculta la paginación</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4 conditional-field" data-depends-on="show_pagination" data-condition="checked">
|
||||||
|
<label for="pagination_position"><strong>Posición de la paginación</strong></label>
|
||||||
|
<select class="form-select" id="pagination_position" name="pagination[pagination_position]">
|
||||||
|
<option value="left" <?php selected($paginationPosition, 'left'); ?>>Izquierda</option>
|
||||||
|
<option value="center" <?php selected($paginationPosition, 'center'); ?>>Centro</option>
|
||||||
|
<option value="right" <?php selected($paginationPosition, 'right'); ?>>Derecha</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Alineación de la paginación</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(): string
|
||||||
|
{
|
||||||
|
$containerClasses = $this->data['styles']['container_classes'] ?? 'my-5 related-posts';
|
||||||
|
$gridGap = $this->data['styles']['grid_gap'] ?? '4';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="container_classes"><strong>Clases CSS del contenedor</strong></label>
|
||||||
|
<input type="text" class="form-control" id="container_classes" name="styles[container_classes]" value="<?php echo esc_attr($containerClasses); ?>">
|
||||||
|
<small class="form-text text-muted">Clases CSS adicionales para el contenedor principal</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="grid_gap"><strong>Espaciado del grid</strong></label>
|
||||||
|
<select class="form-select" id="grid_gap" name="styles[grid_gap]">
|
||||||
|
<option value="1" <?php selected($gridGap, '1'); ?>>Muy pequeño</option>
|
||||||
|
<option value="2" <?php selected($gridGap, '2'); ?>>Pequeño</option>
|
||||||
|
<option value="3" <?php selected($gridGap, '3'); ?>>Normal</option>
|
||||||
|
<option value="4" <?php selected($gridGap, '4'); ?>>Grande</option>
|
||||||
|
<option value="5" <?php selected($gridGap, '5'); ?>>Muy grande</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Espaciado entre cards</small>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>Notas:</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>Los posts se seleccionan según el criterio elegido (categoría, tags, etc.)</li>
|
||||||
|
<li>Si no hay posts relacionados, se mostrarán los más recientes</li>
|
||||||
|
<li>La paginación solo aparece si hay más de una página de resultados</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\RelatedPosts\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
use WP_Query;
|
||||||
|
|
||||||
|
final class RelatedPostsRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
private int $currentPostId;
|
||||||
|
private ?WP_Query $query = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->currentPostId = get_the_ID() ?: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->query = $this->buildQuery($data);
|
||||||
|
|
||||||
|
if (!$this->query->have_posts()) {
|
||||||
|
$this->query = $this->buildFallbackQuery($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->query->have_posts()) {
|
||||||
|
wp_reset_postdata();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerClasses = $data['styles']['container_classes'] ?? 'my-5 related-posts';
|
||||||
|
$sectionTitle = $data['visibility']['section_title'] ?? 'Descubre Más Contenido';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($containerClasses); ?>">
|
||||||
|
<h2 class="h3 mb-4"><?php echo esc_html($sectionTitle); ?></h2>
|
||||||
|
<?php echo $this->renderPostsGrid($data); ?>
|
||||||
|
<?php echo $this->renderPagination($data); ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
|
||||||
|
wp_reset_postdata();
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildQuery(array $data): WP_Query
|
||||||
|
{
|
||||||
|
$postsPerPage = $data['query']['posts_per_page'] ?? 12;
|
||||||
|
$postSelection = $data['query']['post_selection'] ?? 'category';
|
||||||
|
$excludeCurrent = $data['query']['exclude_current_post'] ?? true;
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => $postsPerPage,
|
||||||
|
'paged' => get_query_var('paged') ? get_query_var('paged') : 1,
|
||||||
|
'ignore_sticky_posts' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($excludeCurrent) {
|
||||||
|
$args['post__not_in'] = [$this->currentPostId];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($postSelection) {
|
||||||
|
case 'category':
|
||||||
|
$args['category__in'] = wp_get_post_categories($this->currentPostId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tags':
|
||||||
|
$tags = wp_get_post_tags($this->currentPostId, ['fields' => 'ids']);
|
||||||
|
if (!empty($tags)) {
|
||||||
|
$args['tag__in'] = $tags;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'both':
|
||||||
|
$args['category__in'] = wp_get_post_categories($this->currentPostId);
|
||||||
|
$tags = wp_get_post_tags($this->currentPostId, ['fields' => 'ids']);
|
||||||
|
if (!empty($tags)) {
|
||||||
|
$args['tag__in'] = $tags;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'random':
|
||||||
|
$args['orderby'] = 'rand';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'recent':
|
||||||
|
default:
|
||||||
|
$args['orderby'] = 'date';
|
||||||
|
$args['order'] = 'DESC';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_Query($args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFallbackQuery(array $data): WP_Query
|
||||||
|
{
|
||||||
|
$postsPerPage = $data['query']['posts_per_page'] ?? 12;
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => $postsPerPage,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'post__not_in' => [$this->currentPostId],
|
||||||
|
'paged' => get_query_var('paged') ? get_query_var('paged') : 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
return new WP_Query($args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderPostsGrid(array $data): string
|
||||||
|
{
|
||||||
|
$columns = $data['layout']['columns'] ?? '3';
|
||||||
|
$gridGap = 'g-' . ($data['styles']['grid_gap'] ?? '4');
|
||||||
|
$columnClass = $this->getColumnClass($columns);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="row <?php echo esc_attr($gridGap); ?>">
|
||||||
|
<?php
|
||||||
|
while ($this->query->have_posts()) {
|
||||||
|
$this->query->the_post();
|
||||||
|
echo $this->renderPostCard($data, $columnClass);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getColumnClass(string $columns): string
|
||||||
|
{
|
||||||
|
$columnMap = [
|
||||||
|
'1' => 'col-12',
|
||||||
|
'2' => 'col-md-6',
|
||||||
|
'3' => 'col-md-4',
|
||||||
|
'4' => 'col-md-3',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $columnMap[$columns] ?? 'col-md-4';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderPostCard(array $data, string $columnClass): string
|
||||||
|
{
|
||||||
|
$cardHeight = $data['layout']['card_height'] ?? 'equal';
|
||||||
|
$heightClass = $cardHeight === 'equal' ? 'h-100' : '';
|
||||||
|
$textCenterClass = $cardHeight === 'equal' ? 'text-center' : '';
|
||||||
|
$dFlexClass = $cardHeight === 'equal' ? 'd-flex align-items-center justify-content-center' : '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($columnClass); ?>">
|
||||||
|
<a href="<?php the_permalink(); ?>" class="text-decoration-none">
|
||||||
|
<div class="card <?php echo esc_attr($heightClass); ?> shadow-sm">
|
||||||
|
<div class="card-body <?php echo esc_attr($dFlexClass); ?> p-4">
|
||||||
|
<h5 class="card-title h6 mb-0 <?php echo esc_attr($textCenterClass); ?>">
|
||||||
|
<?php the_title(); ?>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderPagination(array $data): string
|
||||||
|
{
|
||||||
|
if (!isset($data['pagination']['show_pagination']) || !$data['pagination']['show_pagination']) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->query->max_num_pages <= 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginationPosition = $data['pagination']['pagination_position'] ?? 'center';
|
||||||
|
$positionClass = 'justify-content-' . $paginationPosition;
|
||||||
|
|
||||||
|
$currentPage = max(1, get_query_var('paged'));
|
||||||
|
$totalPages = $this->query->max_num_pages;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<nav aria-label="Navegación de posts relacionados" class="mt-5">
|
||||||
|
<ul class="pagination <?php echo esc_attr($positionClass); ?>">
|
||||||
|
<li class="page-item <?php echo $currentPage === 1 ? 'disabled' : ''; ?>">
|
||||||
|
<a class="page-link" href="<?php echo esc_url(get_pagenum_link(1)); ?>">Inicio</a>
|
||||||
|
</li>
|
||||||
|
<?php
|
||||||
|
$startPage = max(1, $currentPage - 2);
|
||||||
|
$endPage = min($totalPages, $startPage + 4);
|
||||||
|
|
||||||
|
if ($endPage - $startPage < 4) {
|
||||||
|
$startPage = max(1, $endPage - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = $startPage; $i <= $endPage; $i++):
|
||||||
|
$activeClass = $i === $currentPage ? 'active' : '';
|
||||||
|
?>
|
||||||
|
<li class="page-item <?php echo esc_attr($activeClass); ?>">
|
||||||
|
<a class="page-link" href="<?php echo esc_url(get_pagenum_link($i)); ?>">
|
||||||
|
<?php echo esc_html($i); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="<?php echo esc_url(get_pagenum_link($endPage)); ?>">Ver más</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item <?php echo $currentPage === $totalPages ? 'disabled' : ''; ?>">
|
||||||
|
<a class="page-link" href="<?php echo esc_url(get_pagenum_link($totalPages)); ?>">Fin</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'related-posts';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\ShareButtons\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class ShareButtonsFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder share-buttons-form">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#networks-tab" type="button">Redes Sociales</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#button-styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#advanced-tab" type="button">Avanzado</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="networks-tab"><?php echo $this->buildNetworksTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="button-styles-tab"><?php echo $this->buildButtonStylesTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="advanced-tab"><?php echo $this->buildAdvancedTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilityTab(): string
|
||||||
|
{
|
||||||
|
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
|
||||||
|
$showLabel = $this->data['visibility']['show_label'] ?? true;
|
||||||
|
$labelText = $this->data['visibility']['label_text'] ?? 'Compartir:';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="is_enabled"><strong>Activar botones de compartir</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Activa o desactiva los botones de compartir en redes sociales</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_label" name="visibility[show_label]" value="1" <?php checked($showLabel, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_label">Mostrar etiqueta</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Muestra u oculta el texto de etiqueta antes de los botones</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4 conditional-field" data-depends-on="show_label" data-condition="checked">
|
||||||
|
<label for="label_text">Texto de etiqueta</label>
|
||||||
|
<input type="text" class="form-control" id="label_text" name="visibility[label_text]" value="<?php echo esc_attr($labelText); ?>" maxlength="100">
|
||||||
|
<small class="form-text text-muted">Texto que aparece antes de los botones</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNetworksTab(): string
|
||||||
|
{
|
||||||
|
$enabledNetworks = $this->data['networks']['enabled_networks'] ?? ['facebook', 'instagram', 'linkedin', 'whatsapp', 'twitter', 'email'];
|
||||||
|
$showLabels = $this->data['networks']['show_network_labels'] ?? false;
|
||||||
|
|
||||||
|
$networks = [
|
||||||
|
'facebook' => 'Facebook',
|
||||||
|
'instagram' => 'Instagram',
|
||||||
|
'linkedin' => 'LinkedIn',
|
||||||
|
'whatsapp' => 'WhatsApp',
|
||||||
|
'twitter' => 'Twitter / X',
|
||||||
|
'email' => 'Email',
|
||||||
|
];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label><strong>Redes sociales habilitadas</strong></label>
|
||||||
|
<p class="text-muted small">Selecciona qué redes sociales mostrar en los botones de compartir</p>
|
||||||
|
<?php foreach ($networks as $key => $label): ?>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="network_<?php echo esc_attr($key); ?>" name="networks[enabled_networks][]" value="<?php echo esc_attr($key); ?>" <?php checked(in_array($key, $enabledNetworks, true), true); ?>>
|
||||||
|
<label class="form-check-label" for="network_<?php echo esc_attr($key); ?>"><?php echo esc_html($label); ?></label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_network_labels" name="networks[show_network_labels]" value="1" <?php checked($showLabels, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_network_labels">Mostrar nombres de redes</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Muestra el nombre de la red social junto al icono</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildButtonStylesTab(): string
|
||||||
|
{
|
||||||
|
$buttonStyle = $this->data['button_styles']['button_style'] ?? 'outline';
|
||||||
|
$buttonSize = $this->data['button_styles']['button_size'] ?? 'sm';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_style"><strong>Estilo de botones</strong></label>
|
||||||
|
<select class="form-select" id="button_style" name="button_styles[button_style]">
|
||||||
|
<option value="outline" <?php selected($buttonStyle, 'outline'); ?>>Outline (contorno)</option>
|
||||||
|
<option value="solid" <?php selected($buttonStyle, 'solid'); ?>>Solid (relleno)</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Estilo visual de los botones</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="button_size"><strong>Tamaño de botones</strong></label>
|
||||||
|
<select class="form-select" id="button_size" name="button_styles[button_size]">
|
||||||
|
<option value="sm" <?php selected($buttonSize, 'sm'); ?>>Pequeño</option>
|
||||||
|
<option value="md" <?php selected($buttonSize, 'md'); ?>>Mediano</option>
|
||||||
|
<option value="lg" <?php selected($buttonSize, 'lg'); ?>>Grande</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Tamaño de los botones</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAdvancedTab(): string
|
||||||
|
{
|
||||||
|
$containerClasses = $this->data['advanced']['container_classes'] ?? 'my-5 py-4 border-top';
|
||||||
|
$wrapperClasses = $this->data['advanced']['buttons_wrapper_classes'] ?? 'd-flex gap-2 flex-wrap share-buttons';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="container_classes"><strong>Clases CSS del contenedor</strong></label>
|
||||||
|
<input type="text" class="form-control" id="container_classes" name="advanced[container_classes]" value="<?php echo esc_attr($containerClasses); ?>">
|
||||||
|
<small class="form-text text-muted">Clases CSS adicionales para el contenedor principal</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="buttons_wrapper_classes"><strong>Clases CSS del wrapper de botones</strong></label>
|
||||||
|
<input type="text" class="form-control" id="buttons_wrapper_classes" name="advanced[buttons_wrapper_classes]" value="<?php echo esc_attr($wrapperClasses); ?>">
|
||||||
|
<small class="form-text text-muted">Clases CSS para el wrapper de los botones</small>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>Notas:</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>Instagram no tiene API de compartir directa, se muestra como enlace inactivo (#)</li>
|
||||||
|
<li>Las URLs de compartir se generan automáticamente con el permalink del post</li>
|
||||||
|
<li>Todos los botones incluyen aria-label para lectores de pantalla</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\ShareButtons\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class ShareButtonsRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
private array $networksConfig = [
|
||||||
|
'facebook' => [
|
||||||
|
'url_template' => 'https://www.facebook.com/sharer/sharer.php?u={url}',
|
||||||
|
'icon_class' => 'bi bi-facebook',
|
||||||
|
'button_class' => 'btn-outline-primary',
|
||||||
|
'aria_label' => 'Compartir en Facebook',
|
||||||
|
],
|
||||||
|
'instagram' => [
|
||||||
|
'url_template' => '#',
|
||||||
|
'icon_class' => 'bi bi-instagram',
|
||||||
|
'button_class' => 'btn-outline-danger',
|
||||||
|
'aria_label' => 'Compartir en Instagram',
|
||||||
|
],
|
||||||
|
'linkedin' => [
|
||||||
|
'url_template' => 'https://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}',
|
||||||
|
'icon_class' => 'bi bi-linkedin',
|
||||||
|
'button_class' => 'btn-outline-info',
|
||||||
|
'aria_label' => 'Compartir en LinkedIn',
|
||||||
|
],
|
||||||
|
'whatsapp' => [
|
||||||
|
'url_template' => 'https://wa.me/?text={title}%20{url}',
|
||||||
|
'icon_class' => 'bi bi-whatsapp',
|
||||||
|
'button_class' => 'btn-outline-success',
|
||||||
|
'aria_label' => 'Compartir en WhatsApp',
|
||||||
|
],
|
||||||
|
'twitter' => [
|
||||||
|
'url_template' => 'https://twitter.com/intent/tweet?url={url}&text={title}',
|
||||||
|
'icon_class' => 'bi bi-twitter-x',
|
||||||
|
'button_class' => 'btn-outline-dark',
|
||||||
|
'aria_label' => 'Compartir en X',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'url_template' => 'mailto:?subject={title}&body={url}',
|
||||||
|
'icon_class' => 'bi bi-envelope',
|
||||||
|
'button_class' => 'btn-outline-secondary',
|
||||||
|
'aria_label' => 'Compartir por Email',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$enabledNetworks = $data['networks']['enabled_networks'] ?? [];
|
||||||
|
if (empty($enabledNetworks)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerClasses = $data['advanced']['container_classes'] ?? 'my-5 py-4 border-top';
|
||||||
|
$wrapperClasses = $data['advanced']['buttons_wrapper_classes'] ?? 'd-flex gap-2 flex-wrap share-buttons';
|
||||||
|
|
||||||
|
$html = sprintf('<div class="%s">', esc_attr($containerClasses));
|
||||||
|
|
||||||
|
if ($this->shouldShowLabel($data)) {
|
||||||
|
$labelText = $data['visibility']['label_text'] ?? 'Compartir:';
|
||||||
|
$html .= sprintf('<p class="mb-3 text-muted">%s</p>', esc_html($labelText));
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= sprintf('<div class="%s">', esc_attr($wrapperClasses));
|
||||||
|
$html .= $this->renderButtons($data, $enabledNetworks);
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowLabel(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['show_label']) &&
|
||||||
|
$data['visibility']['show_label'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderButtons(array $data, array $enabledNetworks): string
|
||||||
|
{
|
||||||
|
$postUrl = get_permalink() ?: '';
|
||||||
|
$postTitle = get_the_title() ?: '';
|
||||||
|
$buttonStyle = $data['button_styles']['button_style'] ?? 'outline';
|
||||||
|
$buttonSize = $data['button_styles']['button_size'] ?? 'sm';
|
||||||
|
$showLabels = $data['networks']['show_network_labels'] ?? false;
|
||||||
|
|
||||||
|
$output = '';
|
||||||
|
|
||||||
|
foreach ($enabledNetworks as $network) {
|
||||||
|
if (!isset($this->networksConfig[$network])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$networkConfig = $this->networksConfig[$network];
|
||||||
|
$url = $this->buildShareUrl($network, $networkConfig['url_template'], $postUrl, $postTitle);
|
||||||
|
$buttonClass = $this->getButtonClass($networkConfig['button_class'], $buttonStyle, $buttonSize);
|
||||||
|
|
||||||
|
$label = $showLabels ? ' ' . esc_html(ucfirst($network)) : '';
|
||||||
|
|
||||||
|
$output .= sprintf(
|
||||||
|
'<a href="%s" class="%s" aria-label="%s" target="_blank" rel="noopener noreferrer">
|
||||||
|
<i class="%s"></i>%s
|
||||||
|
</a>',
|
||||||
|
esc_url($url),
|
||||||
|
esc_attr($buttonClass),
|
||||||
|
esc_attr($networkConfig['aria_label']),
|
||||||
|
esc_attr($networkConfig['icon_class']),
|
||||||
|
$label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildShareUrl(string $network, string $template, string $postUrl, string $postTitle): string
|
||||||
|
{
|
||||||
|
if ($network === 'instagram') {
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacements = [
|
||||||
|
'{url}' => urlencode($postUrl),
|
||||||
|
'{title}' => urlencode($postTitle),
|
||||||
|
];
|
||||||
|
|
||||||
|
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getButtonClass(string $networkClass, string $buttonStyle, string $buttonSize): string
|
||||||
|
{
|
||||||
|
$baseClass = 'btn';
|
||||||
|
$sizeClass = 'btn-' . $buttonSize;
|
||||||
|
|
||||||
|
if ($buttonStyle === 'solid') {
|
||||||
|
$networkClass = str_replace('btn-outline-', 'btn-', $networkClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$baseClass} {$networkClass} {$sizeClass}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'share-buttons';
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/Shared/Domain/.gitkeep
Normal file
0
src/Shared/Domain/.gitkeep
Normal file
76
src/Shared/Domain/ComponentDefaultsRepositoryInterface.php
Normal file
76
src/Shared/Domain/ComponentDefaultsRepositoryInterface.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Shared\Domain;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentDefaultsRepositoryInterface - Contrato para valores por defecto de componentes
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Definir contrato para gestión de configuración por defecto de componentes
|
||||||
|
*
|
||||||
|
* PROPÓSITO:
|
||||||
|
* - Almacenar configuración "factory" de cada tipo de componente
|
||||||
|
* - Permitir resetear componentes a valores por defecto
|
||||||
|
* - Proporcionar plantillas para nuevos componentes
|
||||||
|
*
|
||||||
|
* CASO DE USO:
|
||||||
|
* ```php
|
||||||
|
* // Crear nuevo componente top_bar con valores por defecto
|
||||||
|
* $defaults = $defaultsRepo->getByName(ComponentName::fromString('top_bar'));
|
||||||
|
* $component = new Component(
|
||||||
|
* ComponentName::fromString('top_bar'),
|
||||||
|
* $defaults // configuración por defecto
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // Resetear componente a valores por defecto
|
||||||
|
* $component = $component->updateConfiguration($defaults);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Contracts
|
||||||
|
*/
|
||||||
|
interface ComponentDefaultsRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Obtener configuración por defecto de un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @return ComponentConfiguration Configuración por defecto
|
||||||
|
*/
|
||||||
|
public function getByName(ComponentName $name): ComponentConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar configuración por defecto para un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @param ComponentConfiguration $configuration Configuración por defecto
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function save(ComponentName $name, ComponentConfiguration $configuration): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si existen defaults para un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(ComponentName $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los defaults
|
||||||
|
*
|
||||||
|
* @return array<string, ComponentConfiguration> Array asociativo nombre => configuración
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar defaults de un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return bool True si se eliminó, false si no existía
|
||||||
|
*/
|
||||||
|
public function delete(ComponentName $name): bool;
|
||||||
|
}
|
||||||
124
src/Shared/Domain/ComponentRepositoryInterface.php
Normal file
124
src/Shared/Domain/ComponentRepositoryInterface.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Shared\Domain;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\ValueObjects\ComponentName;
|
||||||
|
use ROITheme\Component\Domain\Exceptions\ComponentNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentRepositoryInterface - Contrato para persistencia de componentes
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Definir contrato para operaciones CRUD de componentes
|
||||||
|
*
|
||||||
|
* INVERSIÓN DE DEPENDENCIAS:
|
||||||
|
* - Esta interfaz está en Domain (núcleo)
|
||||||
|
* - Infrastructure implementa esta interfaz
|
||||||
|
* - Application depende de esta interfaz (NO de la implementación)
|
||||||
|
*
|
||||||
|
* IMPLEMENTACIONES ESPERADAS:
|
||||||
|
* - WordPressComponentRepository (usa wpdb y tablas WP)
|
||||||
|
* - InMemoryComponentRepository (para testing)
|
||||||
|
* - FileSystemComponentRepository (futuro: archivos JSON)
|
||||||
|
*
|
||||||
|
* PRINCIPIOS:
|
||||||
|
* - Métodos retornan entidades de dominio (Component)
|
||||||
|
* - Parámetros son Value Objects o primitivos
|
||||||
|
* - Excepciones son de dominio
|
||||||
|
* - Sin conocimiento de WordPress/BD
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* // En Application Layer
|
||||||
|
* class SaveComponentUseCase {
|
||||||
|
* public function __construct(
|
||||||
|
* private ComponentRepositoryInterface $repository
|
||||||
|
* ) {}
|
||||||
|
*
|
||||||
|
* public function execute(SaveComponentRequest $request): void {
|
||||||
|
* $component = new Component(...);
|
||||||
|
* $this->repository->save($component);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Contracts
|
||||||
|
*/
|
||||||
|
interface ComponentRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Guardar o actualizar un componente
|
||||||
|
*
|
||||||
|
* Si el componente ya existe (por nombre), se actualiza.
|
||||||
|
* Si no existe, se crea.
|
||||||
|
*
|
||||||
|
* @param Component $component Componente a guardar
|
||||||
|
* @return Component Componente guardado (con timestamps actualizados)
|
||||||
|
*/
|
||||||
|
public function save(Component $component): Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar componente por nombre
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente
|
||||||
|
* @return Component|null Componente encontrado o null
|
||||||
|
*/
|
||||||
|
public function findByName(ComponentName $name): ?Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componente por nombre (lanza excepción si no existe)
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return Component
|
||||||
|
* @throws ComponentNotFoundException
|
||||||
|
*/
|
||||||
|
public function getByName(ComponentName $name): Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los componentes
|
||||||
|
*
|
||||||
|
* @return Component[] Array de componentes
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componentes habilitados
|
||||||
|
*
|
||||||
|
* @return Component[] Array de componentes habilitados
|
||||||
|
*/
|
||||||
|
public function findEnabled(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si existe un componente con el nombre dado
|
||||||
|
*
|
||||||
|
* @param ComponentName $name
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(ComponentName $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un componente
|
||||||
|
*
|
||||||
|
* @param ComponentName $name Nombre del componente a eliminar
|
||||||
|
* @return bool True si se eliminó, false si no existía
|
||||||
|
*/
|
||||||
|
public function delete(ComponentName $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener cantidad total de componentes
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function count(): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener componentes por grupo de configuración
|
||||||
|
*
|
||||||
|
* Ejemplo: Obtener todos los componentes que tienen configuración de 'content'
|
||||||
|
*
|
||||||
|
* @param string $group Grupo de configuración (visibility, content, styles, general)
|
||||||
|
* @return Component[]
|
||||||
|
*/
|
||||||
|
public function findByConfigGroup(string $group): array;
|
||||||
|
}
|
||||||
199
src/Shared/Domain/ValidationServiceInterface.php
Normal file
199
src/Shared/Domain/ValidationServiceInterface.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\Shared\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ValidationServiceInterface - Contrato para servicio de validación
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Definir contrato para validación de datos de entrada
|
||||||
|
*
|
||||||
|
* PROPÓSITO:
|
||||||
|
* - Validar datos antes de crear entidades de dominio
|
||||||
|
* - Sanitizar entrada del usuario
|
||||||
|
* - Retornar errores de validación estructurados
|
||||||
|
*
|
||||||
|
* UBICACIÓN EN ARQUITECTURA:
|
||||||
|
* - Interfaz definida en Domain
|
||||||
|
* - Implementación en Infrastructure (puede usar WordPress functions)
|
||||||
|
* - Usado por Application Layer (Use Cases)
|
||||||
|
*
|
||||||
|
* IMPLEMENTACIONES ESPERADAS:
|
||||||
|
* - WordPressValidationService (usa funciones de sanitización de WordPress)
|
||||||
|
* - StrictValidationService (validación más estricta para prod)
|
||||||
|
* - LenientValidationService (validación más permisiva para dev)
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* ```php
|
||||||
|
* // En Application Layer
|
||||||
|
* class SaveComponentUseCase {
|
||||||
|
* public function __construct(
|
||||||
|
* private ValidationServiceInterface $validator
|
||||||
|
* ) {}
|
||||||
|
*
|
||||||
|
* public function execute(SaveComponentRequest $request): void {
|
||||||
|
* $result = $this->validator->validate(
|
||||||
|
* $request->getData(),
|
||||||
|
* $request->getComponentName()
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* if (!$result->isValid()) {
|
||||||
|
* throw new ValidationException($result->getErrors());
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // ... crear componente con datos validados
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Contracts
|
||||||
|
*/
|
||||||
|
interface ValidationServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validar datos de un componente
|
||||||
|
*
|
||||||
|
* @param array $data Datos a validar
|
||||||
|
* @param string $componentName Nombre del componente (para reglas específicas)
|
||||||
|
* @return ValidationResult Resultado de validación
|
||||||
|
*/
|
||||||
|
public function validate(array $data, string $componentName): ValidationResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizar datos de entrada
|
||||||
|
*
|
||||||
|
* @param array $data Datos a sanitizar
|
||||||
|
* @param string $componentName Nombre del componente
|
||||||
|
* @return array Datos sanitizados
|
||||||
|
*/
|
||||||
|
public function sanitize(array $data, string $componentName): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar una URL
|
||||||
|
*
|
||||||
|
* @param string $url URL a validar
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidUrl(string $url): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar un color hexadecimal
|
||||||
|
*
|
||||||
|
* @param string $color Color a validar (ej: #000000)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidColor(string $color): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar nombre de componente
|
||||||
|
*
|
||||||
|
* @param string $name Nombre a validar
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidComponentName(string $name): bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ValidationResult - Value Object para resultado de validación
|
||||||
|
*
|
||||||
|
* RESPONSABILIDAD: Encapsular resultado de una validación
|
||||||
|
*
|
||||||
|
* @package ROITheme\Domain\Contracts
|
||||||
|
*/
|
||||||
|
final class ValidationResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param bool $isValid Si la validación pasó
|
||||||
|
* @param array $errors Array de errores (campo => mensaje)
|
||||||
|
* @param array $sanitizedData Datos sanitizados
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private bool $isValid,
|
||||||
|
private array $errors = [],
|
||||||
|
private array $sanitizedData = []
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si la validación pasó
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
return $this->isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener errores de validación
|
||||||
|
*
|
||||||
|
* @return array Array asociativo campo => mensaje
|
||||||
|
*/
|
||||||
|
public function getErrors(): array
|
||||||
|
{
|
||||||
|
return $this->errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener datos sanitizados
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getSanitizedData(): array
|
||||||
|
{
|
||||||
|
return $this->sanitizedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si hay error en campo específico
|
||||||
|
*
|
||||||
|
* @param string $field
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasError(string $field): bool
|
||||||
|
{
|
||||||
|
return isset($this->errors[$field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener error de un campo específico
|
||||||
|
*
|
||||||
|
* @param string $field
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getError(string $field): ?string
|
||||||
|
{
|
||||||
|
return $this->errors[$field] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Resultado exitoso
|
||||||
|
*
|
||||||
|
* @param array $sanitizedData
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function success(array $sanitizedData): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
isValid: true,
|
||||||
|
errors: [],
|
||||||
|
sanitizedData: $sanitizedData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Resultado con errores
|
||||||
|
*
|
||||||
|
* @param array $errors
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function failure(array $errors): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
isValid: false,
|
||||||
|
errors: $errors,
|
||||||
|
sanitizedData: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\TableOfContents\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class TableOfContentsFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="roi-form-builder table-of-contents-form">
|
||||||
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#visibility-tab" type="button">Visibilidad</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#config-tab" type="button">Configuración</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#manual-items-tab" type="button">Items Manuales</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#styles-tab" type="button">Estilos</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="visibility-tab"><?php echo $this->buildVisibilityTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="config-tab"><?php echo $this->buildConfigTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="manual-items-tab"><?php echo $this->buildManualItemsTab(); ?></div>
|
||||||
|
<div class="tab-pane fade" id="styles-tab"><?php echo $this->buildStylesTab(); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilityTab(): string
|
||||||
|
{
|
||||||
|
$isEnabled = $this->data['visibility']['is_enabled'] ?? true;
|
||||||
|
$sticky = $this->data['visibility']['sticky'] ?? true;
|
||||||
|
$showOnMobile = $this->data['visibility']['show_on_mobile'] ?? false;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_enabled" name="visibility[is_enabled]" value="1" <?php checked($isEnabled, true); ?>>
|
||||||
|
<label class="form-check-label" for="is_enabled"><strong>Activar tabla de contenido</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Activa o desactiva el componente de tabla de contenido</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="sticky" name="visibility[sticky]" value="1" <?php checked($sticky, true); ?>>
|
||||||
|
<label class="form-check-label" for="sticky">Posición sticky (fija al hacer scroll)</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Mantiene el TOC visible mientras el usuario navega</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_on_mobile" name="visibility[show_on_mobile]" value="1" <?php checked($showOnMobile, true); ?>>
|
||||||
|
<label class="form-check-label" for="show_on_mobile">Mostrar en dispositivos móviles</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Muestra el TOC en pantallas pequeñas</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildConfigTab(): string
|
||||||
|
{
|
||||||
|
$title = $this->data['config']['title'] ?? 'Tabla de Contenido';
|
||||||
|
$headingLevels = $this->data['config']['heading_levels'] ?? ['h2', 'h3'];
|
||||||
|
$autoGenerate = $this->data['config']['auto_generate'] ?? true;
|
||||||
|
$offsetTop = $this->data['config']['offset_top'] ?? 100;
|
||||||
|
$smoothScroll = $this->data['config']['smooth_scroll'] ?? true;
|
||||||
|
$maxHeight = $this->data['config']['max_height'] ?? 'calc(100vh - 400px)';
|
||||||
|
$customCssClass = $this->data['config']['custom_css_class'] ?? '';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="title"><strong>Título del TOC</strong></label>
|
||||||
|
<input type="text" class="form-control" id="title" name="config[title]" value="<?php echo esc_attr($title); ?>" maxlength="100" required>
|
||||||
|
<small class="form-text text-muted">Título que aparece en el encabezado del TOC</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label><strong>Niveles de encabezados</strong></label>
|
||||||
|
<small class="form-text text-muted d-block mb-2">Selecciona qué niveles incluir en el TOC</small>
|
||||||
|
<?php
|
||||||
|
$availableLevels = ['h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6'];
|
||||||
|
foreach ($availableLevels as $level => $label):
|
||||||
|
?>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="heading_<?php echo $level; ?>" name="config[heading_levels][]" value="<?php echo esc_attr($level); ?>" <?php checked(in_array($level, $headingLevels)); ?>>
|
||||||
|
<label class="form-check-label" for="heading_<?php echo $level; ?>"><?php echo esc_html($label); ?></label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="auto_generate" name="config[auto_generate]" value="1" <?php checked($autoGenerate, true); ?>>
|
||||||
|
<label class="form-check-label" for="auto_generate"><strong>Generar automáticamente desde contenido</strong></label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Si está desactivado, usa items manuales</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="offset_top"><strong>Offset top (px)</strong></label>
|
||||||
|
<input type="number" class="form-control" id="offset_top" name="config[offset_top]" value="<?php echo esc_attr($offsetTop); ?>" min="0" max="500" step="10">
|
||||||
|
<small class="form-text text-muted">Offset desde el top para el ScrollSpy</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="smooth_scroll" name="config[smooth_scroll]" value="1" <?php checked($smoothScroll, true); ?>>
|
||||||
|
<label class="form-check-label" for="smooth_scroll">Scroll suave</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Activar scroll suave al hacer clic en enlaces</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="max_height"><strong>Altura máxima</strong></label>
|
||||||
|
<input type="text" class="form-control" id="max_height" name="config[max_height]" value="<?php echo esc_attr($maxHeight); ?>">
|
||||||
|
<small class="form-text text-muted">CSS válido (ej: 500px, calc(100vh - 200px))</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label for="custom_css_class"><strong>Clase CSS personalizada</strong></label>
|
||||||
|
<input type="text" class="form-control" id="custom_css_class" name="config[custom_css_class]" value="<?php echo esc_attr($customCssClass); ?>">
|
||||||
|
<small class="form-text text-muted">Opcional: agregar clase CSS adicional</small>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildManualItemsTab(): string
|
||||||
|
{
|
||||||
|
$items = $this->data['manual_items']['items'] ?? [];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Nota:</strong> Los items manuales solo se usan si "Generar automáticamente" está desactivado en la pestaña Configuración.
|
||||||
|
</div>
|
||||||
|
<div class="manual-items-list mb-3">
|
||||||
|
<?php
|
||||||
|
if (empty($items)) {
|
||||||
|
$items = [['text' => '', 'anchor' => '', 'level' => 2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($items as $index => $item):
|
||||||
|
?>
|
||||||
|
<div class="card mb-3 manual-item">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Item #<?php echo $index + 1; ?></strong>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger remove-manual-item">
|
||||||
|
<i class="bi bi-trash"></i> Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label>Texto del enlace</label>
|
||||||
|
<input type="text" class="form-control" name="manual_items[items][<?php echo $index; ?>][text]" value="<?php echo esc_attr($item['text'] ?? ''); ?>" placeholder="Ej: Introducción">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label>ID de ancla (sin #)</label>
|
||||||
|
<input type="text" class="form-control" name="manual_items[items][<?php echo $index; ?>][anchor]" value="<?php echo esc_attr($item['anchor'] ?? ''); ?>" placeholder="Ej: introduccion">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>Nivel</label>
|
||||||
|
<select class="form-select" name="manual_items[items][<?php echo $index; ?>][level]">
|
||||||
|
<?php for ($i = 2; $i <= 6; $i++): ?>
|
||||||
|
<option value="<?php echo $i; ?>" <?php selected($item['level'] ?? 2, $i); ?>>H<?php echo $i; ?></option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary add-manual-item">
|
||||||
|
<i class="bi bi-plus-circle"></i> Agregar Item
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
jQuery(document).ready(function($) {
|
||||||
|
$('.add-manual-item').on('click', function() {
|
||||||
|
const container = $('.manual-items-list');
|
||||||
|
const index = container.find('.manual-item').length;
|
||||||
|
|
||||||
|
const itemHTML = `
|
||||||
|
<div class="card mb-3 manual-item">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Item #${index + 1}</strong>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger remove-manual-item">
|
||||||
|
<i class="bi bi-trash"></i> Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label>Texto del enlace</label>
|
||||||
|
<input type="text" class="form-control" name="manual_items[items][${index}][text]" placeholder="Ej: Introducción">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label>ID de ancla (sin #)</label>
|
||||||
|
<input type="text" class="form-control" name="manual_items[items][${index}][anchor]" placeholder="Ej: introduccion">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>Nivel</label>
|
||||||
|
<select class="form-select" name="manual_items[items][${index}][level]">
|
||||||
|
<option value="2">H2</option>
|
||||||
|
<option value="3">H3</option>
|
||||||
|
<option value="4">H4</option>
|
||||||
|
<option value="5">H5</option>
|
||||||
|
<option value="6">H6</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.append(itemHTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.remove-manual-item', function() {
|
||||||
|
$(this).closest('.manual-item').remove();
|
||||||
|
|
||||||
|
$('.manual-item').each(function(index) {
|
||||||
|
$(this).find('.card-header strong').text('Item #' + (index + 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesTab(): string
|
||||||
|
{
|
||||||
|
$backgroundColor = $this->data['styles']['background_color'] ?? '#ffffff';
|
||||||
|
$borderColor = $this->data['styles']['border_color'] ?? '#E6E9ED';
|
||||||
|
$titleColor = $this->data['styles']['title_color'] ?? '#0E2337';
|
||||||
|
$linkColor = $this->data['styles']['link_color'] ?? '#6B7280';
|
||||||
|
$linkHoverColor = $this->data['styles']['link_hover_color'] ?? '#0E2337';
|
||||||
|
$activeBorderColor = $this->data['styles']['active_border_color'] ?? '#0E2337';
|
||||||
|
$activeBgColor = $this->data['styles']['active_bg_color'] ?? '#F9FAFB';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="background_color"><strong>Color de fondo</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="background_color" name="styles[background_color]" value="<?php echo esc_attr($backgroundColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="border_color"><strong>Color de borde</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="border_color" name="styles[border_color]" value="<?php echo esc_attr($borderColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="title_color"><strong>Color del título</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="title_color" name="styles[title_color]" value="<?php echo esc_attr($titleColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="link_color"><strong>Color de enlaces</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="link_color" name="styles[link_color]" value="<?php echo esc_attr($linkColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="link_hover_color"><strong>Color de enlaces (hover)</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="link_hover_color" name="styles[link_hover_color]" value="<?php echo esc_attr($linkHoverColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="active_border_color"><strong>Color de borde activo</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="active_border_color" name="styles[active_border_color]" value="<?php echo esc_attr($activeBorderColor); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label for="active_bg_color"><strong>Color de fondo activo</strong></label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="active_bg_color" name="styles[active_bg_color]" value="<?php echo esc_attr($activeBgColor); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>Notas sobre el TOC:</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>El ScrollSpy requiere Bootstrap 5 cargado en el sitio</li>
|
||||||
|
<li>Los IDs se generan automáticamente desde los encabezados del contenido</li>
|
||||||
|
<li>El TOC es responsive y se adapta a pantallas pequeñas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\TableOfContents\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
use DOMDocument;
|
||||||
|
use DOMXPath;
|
||||||
|
|
||||||
|
final class TableOfContentsRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
private int $currentPostId;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->currentPostId = get_the_ID() ?: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$autoGenerate = $data['config']['auto_generate'] ?? true;
|
||||||
|
$tocItems = $autoGenerate
|
||||||
|
? $this->generateTocFromContent($data)
|
||||||
|
: $this->getManualItems($data);
|
||||||
|
|
||||||
|
if (empty($tocItems)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentId = $component->getId();
|
||||||
|
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
|
||||||
|
$stickyClass = ($data['visibility']['sticky'] ?? true) ? 'sidebar-sticky' : '';
|
||||||
|
$mobileClass = !($data['visibility']['show_on_mobile'] ?? false) ? 'd-none d-lg-block' : '';
|
||||||
|
$title = $data['config']['title'] ?? 'Tabla de Contenido';
|
||||||
|
$maxHeight = $data['config']['max_height'] ?? 'calc(100vh - 400px)';
|
||||||
|
$offsetTop = $data['config']['offset_top'] ?? 100;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<?php if (!empty($customStyles)): ?>
|
||||||
|
<style>
|
||||||
|
<?php echo $customStyles; ?>
|
||||||
|
</style>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div id="<?php echo esc_attr($componentId); ?>"
|
||||||
|
class="toc-container <?php echo esc_attr($stickyClass . ' ' . $mobileClass . ' ' . ($data['config']['custom_css_class'] ?? '')); ?>"
|
||||||
|
data-bs-spy="scroll"
|
||||||
|
data-bs-offset="<?php echo esc_attr($offsetTop); ?>"
|
||||||
|
style="max-height: <?php echo esc_attr($maxHeight); ?>;">
|
||||||
|
|
||||||
|
<h4><?php echo esc_html($title); ?></h4>
|
||||||
|
|
||||||
|
<ol class="list-unstyled toc-list">
|
||||||
|
<?php foreach ($tocItems as $item): ?>
|
||||||
|
<?php
|
||||||
|
$text = $item['text'] ?? '';
|
||||||
|
$anchor = $item['anchor'] ?? '';
|
||||||
|
$level = $item['level'] ?? 2;
|
||||||
|
|
||||||
|
if (empty($text) || empty($anchor)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indentClass = $level > 2 ? 'toc-level-' . $level : '';
|
||||||
|
?>
|
||||||
|
<li class="<?php echo esc_attr($indentClass); ?>">
|
||||||
|
<a href="#<?php echo esc_attr($anchor); ?>"
|
||||||
|
class="toc-link"
|
||||||
|
data-level="<?php echo esc_attr($level); ?>">
|
||||||
|
<?php echo esc_html($text); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($data['config']['smooth_scroll'] ?? true): ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tocLinks = document.querySelectorAll('.toc-link');
|
||||||
|
tocLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = this.getAttribute('href');
|
||||||
|
const targetElement = document.querySelector(targetId);
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const offsetTop = <?php echo intval($offsetTop); ?>;
|
||||||
|
const elementPosition = targetElement.getBoundingClientRect().top;
|
||||||
|
const offsetPosition = elementPosition + window.pageYOffset - offsetTop;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof bootstrap === 'undefined') {
|
||||||
|
const sections = document.querySelectorAll('[id]');
|
||||||
|
const navLinks = document.querySelectorAll('.toc-link');
|
||||||
|
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
let current = '';
|
||||||
|
sections.forEach(section => {
|
||||||
|
const sectionTop = section.offsetTop;
|
||||||
|
if (pageYOffset >= (sectionTop - <?php echo intval($offsetTop) + 50; ?>)) {
|
||||||
|
current = section.getAttribute('id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
navLinks.forEach(link => {
|
||||||
|
link.classList.remove('active');
|
||||||
|
if (link.getAttribute('href') === '#' + current) {
|
||||||
|
link.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateTocFromContent(array $data): array
|
||||||
|
{
|
||||||
|
global $post;
|
||||||
|
|
||||||
|
if (!$post || empty($post->post_content)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$headingLevels = $data['config']['heading_levels'] ?? ['h2', 'h3'];
|
||||||
|
$content = apply_filters('the_content', $post->post_content);
|
||||||
|
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$xpathQuery = implode(' | ', array_map(function($level) {
|
||||||
|
return '//' . $level;
|
||||||
|
}, $headingLevels));
|
||||||
|
|
||||||
|
$headings = $xpath->query($xpathQuery);
|
||||||
|
|
||||||
|
if ($headings->length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tocItems = [];
|
||||||
|
$headingCounter = [];
|
||||||
|
|
||||||
|
foreach ($headings as $heading) {
|
||||||
|
$tagName = strtolower($heading->tagName);
|
||||||
|
$level = intval(substr($tagName, 1));
|
||||||
|
$text = trim($heading->textContent);
|
||||||
|
|
||||||
|
if (empty($text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingId = $heading->getAttribute('id');
|
||||||
|
$anchor = !empty($existingId)
|
||||||
|
? $existingId
|
||||||
|
: $this->generateAnchorId($text, $headingCounter);
|
||||||
|
|
||||||
|
$tocItems[] = [
|
||||||
|
'text' => $text,
|
||||||
|
'anchor' => $anchor,
|
||||||
|
'level' => $level
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tocItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getManualItems(array $data): array
|
||||||
|
{
|
||||||
|
return $data['manual_items']['items'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateAnchorId(string $text, array &$counter): string
|
||||||
|
{
|
||||||
|
$id = strtolower($text);
|
||||||
|
$id = remove_accents($id);
|
||||||
|
$id = preg_replace('/[^a-z0-9]+/', '-', $id);
|
||||||
|
$id = trim($id, '-');
|
||||||
|
|
||||||
|
$baseId = $id;
|
||||||
|
$count = 1;
|
||||||
|
|
||||||
|
while (isset($counter[$id])) {
|
||||||
|
$id = $baseId . '-' . $count;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counter[$id] = true;
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCustomStyles(string $componentId, array $styles): string
|
||||||
|
{
|
||||||
|
if (empty($styles)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$css = [];
|
||||||
|
|
||||||
|
if (isset($styles['background_color'])) {
|
||||||
|
$css[] = "#$componentId.toc-container { background: {$styles['background_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['border_color'])) {
|
||||||
|
$css[] = "#$componentId.toc-container { border-color: {$styles['border_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['title_color'])) {
|
||||||
|
$css[] = "#$componentId.toc-container h4 { color: {$styles['title_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['link_color'])) {
|
||||||
|
$css[] = "#$componentId .toc-link { color: {$styles['link_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['link_hover_color'])) {
|
||||||
|
$css[] = "#$componentId .toc-link:hover { color: {$styles['link_hover_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['active_border_color'])) {
|
||||||
|
$css[] = "#$componentId .toc-link.active { border-left-color: {$styles['active_border_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($styles['active_bg_color'])) {
|
||||||
|
$css[] = "#$componentId .toc-link.active { background-color: {$styles['active_bg_color']}; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $css);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'table-of-contents';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\TopNotificationBar\Infrastructure\Presentation\Admin;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\FormBuilderInterface;
|
||||||
|
|
||||||
|
final class TopNotificationBarFormBuilder implements FormBuilderInterface
|
||||||
|
{
|
||||||
|
private string $componentId;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(string $componentId, array $data = [])
|
||||||
|
{
|
||||||
|
$this->componentId = $componentId;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): string
|
||||||
|
{
|
||||||
|
$data = $this->data;
|
||||||
|
$componentId = $this->componentId;
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-builder roi-top-notification-bar-form">';
|
||||||
|
|
||||||
|
// Sección de Visibilidad
|
||||||
|
$html .= $this->buildVisibilitySection($data, $componentId);
|
||||||
|
|
||||||
|
// Sección de Contenido
|
||||||
|
$html .= $this->buildContentSection($data, $componentId);
|
||||||
|
|
||||||
|
// Sección de Estilos
|
||||||
|
$html .= $this->buildStylesSection($data, $componentId);
|
||||||
|
|
||||||
|
// Vista previa
|
||||||
|
$html .= $this->buildPreviewSection($data);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
// Agregar scripts de formulario
|
||||||
|
$html .= $this->buildFormScripts($componentId);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildVisibilitySection(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="roi-form-section" data-section="visibility">';
|
||||||
|
$html .= '<h3 class="roi-form-section-title">Visibilidad</h3>';
|
||||||
|
$html .= '<div class="roi-form-section-content">';
|
||||||
|
|
||||||
|
// Is Enabled
|
||||||
|
$isEnabled = $data['visibility']['is_enabled'] ?? true;
|
||||||
|
$html .= $this->buildToggle(
|
||||||
|
'is_enabled',
|
||||||
|
'Mostrar barra de notificación',
|
||||||
|
$isEnabled,
|
||||||
|
$componentId,
|
||||||
|
'Activa o desactiva la barra de notificación superior'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show On Pages
|
||||||
|
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'show_on_pages',
|
||||||
|
'Mostrar en',
|
||||||
|
$showOn,
|
||||||
|
[
|
||||||
|
'all' => 'Todas las páginas',
|
||||||
|
'home' => 'Solo página de inicio',
|
||||||
|
'posts' => 'Solo posts individuales',
|
||||||
|
'pages' => 'Solo páginas',
|
||||||
|
'custom' => 'Páginas específicas'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Define en qué páginas se mostrará la barra'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom Page IDs
|
||||||
|
$customPageIds = $data['visibility']['custom_page_ids'] ?? '';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'custom_page_ids',
|
||||||
|
'IDs de páginas específicas',
|
||||||
|
$customPageIds,
|
||||||
|
$componentId,
|
||||||
|
'IDs de páginas separados por comas',
|
||||||
|
'Ej: 1,5,10',
|
||||||
|
['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hide On Mobile
|
||||||
|
$hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false;
|
||||||
|
$html .= $this->buildToggle(
|
||||||
|
'hide_on_mobile',
|
||||||
|
'Ocultar en dispositivos móviles',
|
||||||
|
$hideOnMobile,
|
||||||
|
$componentId,
|
||||||
|
'Oculta la barra en pantallas menores a 768px'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Is Dismissible
|
||||||
|
$isDismissible = $data['visibility']['is_dismissible'] ?? false;
|
||||||
|
$html .= $this->buildToggle(
|
||||||
|
'is_dismissible',
|
||||||
|
'Permitir cerrar',
|
||||||
|
$isDismissible,
|
||||||
|
$componentId,
|
||||||
|
'Agrega botón X para que el usuario pueda cerrar la barra'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dismissible Cookie Days
|
||||||
|
$cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7;
|
||||||
|
$html .= $this->buildNumberField(
|
||||||
|
'dismissible_cookie_days',
|
||||||
|
'Días antes de volver a mostrar',
|
||||||
|
$cookieDays,
|
||||||
|
$componentId,
|
||||||
|
'Días que permanece oculta después de cerrarla',
|
||||||
|
1,
|
||||||
|
365,
|
||||||
|
['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContentSection(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="roi-form-section" data-section="content">';
|
||||||
|
$html .= '<h3 class="roi-form-section-title">Contenido</h3>';
|
||||||
|
$html .= '<div class="roi-form-section-content">';
|
||||||
|
|
||||||
|
// Icon Type
|
||||||
|
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'icon_type',
|
||||||
|
'Tipo de ícono',
|
||||||
|
$iconType,
|
||||||
|
[
|
||||||
|
'bootstrap' => 'Bootstrap Icons',
|
||||||
|
'custom' => 'Imagen personalizada',
|
||||||
|
'none' => 'Sin ícono'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Selecciona el tipo de ícono a mostrar'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bootstrap Icon
|
||||||
|
$bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'bootstrap_icon',
|
||||||
|
'Clase de ícono Bootstrap',
|
||||||
|
$bootstrapIcon,
|
||||||
|
$componentId,
|
||||||
|
'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)',
|
||||||
|
'Ej: bi-megaphone-fill',
|
||||||
|
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom Icon URL
|
||||||
|
$customIconUrl = $data['content']['custom_icon_url'] ?? '';
|
||||||
|
$html .= $this->buildMediaField(
|
||||||
|
'custom_icon_url',
|
||||||
|
'Imagen personalizada',
|
||||||
|
$customIconUrl,
|
||||||
|
$componentId,
|
||||||
|
'Sube una imagen personalizada (recomendado: PNG 24x24px)',
|
||||||
|
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Announcement Label
|
||||||
|
$announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'announcement_label',
|
||||||
|
'Etiqueta del anuncio',
|
||||||
|
$announcementLabel,
|
||||||
|
$componentId,
|
||||||
|
'Texto destacado en negrita antes del mensaje',
|
||||||
|
'Ej: Nuevo:, Importante:, Aviso:'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Announcement Text
|
||||||
|
$announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
|
||||||
|
$html .= $this->buildTextArea(
|
||||||
|
'announcement_text',
|
||||||
|
'Texto del anuncio',
|
||||||
|
$announcementText,
|
||||||
|
$componentId,
|
||||||
|
'Mensaje principal del anuncio (máximo 200 caracteres)',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link Enabled
|
||||||
|
$linkEnabled = $data['content']['link_enabled'] ?? true;
|
||||||
|
$html .= $this->buildToggle(
|
||||||
|
'link_enabled',
|
||||||
|
'Mostrar enlace',
|
||||||
|
$linkEnabled,
|
||||||
|
$componentId,
|
||||||
|
'Activa o desactiva el enlace de acción'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link Text
|
||||||
|
$linkText = $data['content']['link_text'] ?? 'Ver Catálogo';
|
||||||
|
$html .= $this->buildTextField(
|
||||||
|
'link_text',
|
||||||
|
'Texto del enlace',
|
||||||
|
$linkText,
|
||||||
|
$componentId,
|
||||||
|
'Texto del enlace de acción',
|
||||||
|
'',
|
||||||
|
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link URL
|
||||||
|
$linkUrl = $data['content']['link_url'] ?? '#';
|
||||||
|
$html .= $this->buildUrlField(
|
||||||
|
'link_url',
|
||||||
|
'URL del enlace',
|
||||||
|
$linkUrl,
|
||||||
|
$componentId,
|
||||||
|
'URL de destino del enlace',
|
||||||
|
'https://',
|
||||||
|
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link Target
|
||||||
|
$linkTarget = $data['content']['link_target'] ?? '_self';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'link_target',
|
||||||
|
'Abrir enlace en',
|
||||||
|
$linkTarget,
|
||||||
|
[
|
||||||
|
'_self' => 'Misma ventana',
|
||||||
|
'_blank' => 'Nueva ventana'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Define cómo se abrirá el enlace',
|
||||||
|
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStylesSection(array $data, string $componentId): string
|
||||||
|
{
|
||||||
|
$html = '<div class="roi-form-section" data-section="styles">';
|
||||||
|
$html .= '<h3 class="roi-form-section-title">Estilos</h3>';
|
||||||
|
$html .= '<div class="roi-form-section-content">';
|
||||||
|
|
||||||
|
// Background Color
|
||||||
|
$bgColor = $data['styles']['background_color'] ?? '#FF8600';
|
||||||
|
$html .= $this->buildColorField(
|
||||||
|
'background_color',
|
||||||
|
'Color de fondo',
|
||||||
|
$bgColor,
|
||||||
|
$componentId,
|
||||||
|
'Color de fondo de la barra (por defecto: orange primary)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Text Color
|
||||||
|
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||||
|
$html .= $this->buildColorField(
|
||||||
|
'text_color',
|
||||||
|
'Color del texto',
|
||||||
|
$textColor,
|
||||||
|
$componentId,
|
||||||
|
'Color del texto del anuncio'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link Color
|
||||||
|
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
|
||||||
|
$html .= $this->buildColorField(
|
||||||
|
'link_color',
|
||||||
|
'Color del enlace',
|
||||||
|
$linkColor,
|
||||||
|
$componentId,
|
||||||
|
'Color del enlace de acción'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Font Size
|
||||||
|
$fontSize = $data['styles']['font_size'] ?? 'small';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'font_size',
|
||||||
|
'Tamaño de fuente',
|
||||||
|
$fontSize,
|
||||||
|
[
|
||||||
|
'extra-small' => 'Muy pequeño (0.75rem)',
|
||||||
|
'small' => 'Pequeño (0.875rem)',
|
||||||
|
'normal' => 'Normal (1rem)',
|
||||||
|
'large' => 'Grande (1.125rem)'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Tamaño del texto del anuncio'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Padding Vertical
|
||||||
|
$padding = $data['styles']['padding_vertical'] ?? 'normal';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'padding_vertical',
|
||||||
|
'Padding vertical',
|
||||||
|
$padding,
|
||||||
|
[
|
||||||
|
'compact' => 'Compacto (0.5rem)',
|
||||||
|
'normal' => 'Normal (0.75rem)',
|
||||||
|
'spacious' => 'Espacioso (1rem)'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Espaciado vertical interno de la barra'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Text Alignment
|
||||||
|
$alignment = $data['styles']['text_alignment'] ?? 'center';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'text_alignment',
|
||||||
|
'Alineación del texto',
|
||||||
|
$alignment,
|
||||||
|
[
|
||||||
|
'left' => 'Izquierda',
|
||||||
|
'center' => 'Centro',
|
||||||
|
'right' => 'Derecha'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Alineación del contenido de la barra'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Animation Enabled
|
||||||
|
$animationEnabled = $data['styles']['animation_enabled'] ?? false;
|
||||||
|
$html .= $this->buildToggle(
|
||||||
|
'animation_enabled',
|
||||||
|
'Activar animación',
|
||||||
|
$animationEnabled,
|
||||||
|
$componentId,
|
||||||
|
'Activa animación de entrada al cargar la página'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Animation Type
|
||||||
|
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
|
||||||
|
$html .= $this->buildSelect(
|
||||||
|
'animation_type',
|
||||||
|
'Tipo de animación',
|
||||||
|
$animationType,
|
||||||
|
[
|
||||||
|
'slide-down' => 'Deslizar desde arriba',
|
||||||
|
'fade-in' => 'Aparecer gradualmente'
|
||||||
|
],
|
||||||
|
$componentId,
|
||||||
|
'Tipo de animación de entrada',
|
||||||
|
['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPreviewSection(array $data): string
|
||||||
|
{
|
||||||
|
$html = '<div class="roi-form-section roi-preview-section">';
|
||||||
|
$html .= '<h3 class="roi-form-section-title">Vista Previa</h3>';
|
||||||
|
$html .= '<div class="roi-form-section-content">';
|
||||||
|
$html .= '<div id="roi-component-preview" class="border rounded p-3 bg-light">';
|
||||||
|
$html .= '<p class="text-muted">La vista previa se actualizará automáticamente al modificar los campos.</p>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
$checked = $value ? 'checked' : '';
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-toggle mb-3">';
|
||||||
|
$html .= '<div class="form-check form-switch">';
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s]" value="1" %s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
$checked
|
||||||
|
);
|
||||||
|
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '</div>';
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-text mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" placeholder="%s"%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value),
|
||||||
|
esc_attr($placeholder),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-textarea mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<textarea class="form-control" id="%s" name="roi_component[%s][%s]" rows="%d"%s>%s</textarea>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
$rows,
|
||||||
|
$attrString,
|
||||||
|
esc_textarea($value)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-select mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<select class="form-select" id="%s" name="roi_component[%s][%s]"%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($options as $optValue => $optLabel) {
|
||||||
|
$selected = ($value === $optValue) ? 'selected' : '';
|
||||||
|
$html .= sprintf(
|
||||||
|
'<option value="%s" %s>%s</option>',
|
||||||
|
esc_attr($optValue),
|
||||||
|
$selected,
|
||||||
|
esc_html($optLabel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</select>';
|
||||||
|
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-number mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
|
||||||
|
$attrs['type'] = 'number';
|
||||||
|
if ($min !== null) {
|
||||||
|
$attrs['min'] = $min;
|
||||||
|
}
|
||||||
|
if ($max !== null) {
|
||||||
|
$attrs['max'] = $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input class="form-control" id="%s" name="roi_component[%s][%s]" value="%s"%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$attrs['type'] = 'url';
|
||||||
|
return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-color mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '<div class="input-group">';
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s]" value="%s">',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value)
|
||||||
|
);
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="text" class="form-control" value="%s" readonly>',
|
||||||
|
esc_attr($value)
|
||||||
|
);
|
||||||
|
$html .= '</div>';
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string
|
||||||
|
{
|
||||||
|
$fieldId = "roi_{$componentId}_{$name}";
|
||||||
|
|
||||||
|
$html = '<div class="roi-form-field roi-form-field-media mb-3">';
|
||||||
|
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||||
|
$html .= '<div class="input-group">';
|
||||||
|
|
||||||
|
$attrString = $this->buildAttributesString($attrs);
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" readonly%s>',
|
||||||
|
esc_attr($fieldId),
|
||||||
|
esc_attr($componentId),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($value),
|
||||||
|
$attrString
|
||||||
|
);
|
||||||
|
$html .= sprintf(
|
||||||
|
'<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>',
|
||||||
|
esc_attr($fieldId)
|
||||||
|
);
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
if (!empty($value)) {
|
||||||
|
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 100px; height: auto;"></div>', esc_url($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($description)) {
|
||||||
|
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAttributesString(array $attrs): string
|
||||||
|
{
|
||||||
|
$attrString = '';
|
||||||
|
foreach ($attrs as $key => $value) {
|
||||||
|
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
|
||||||
|
}
|
||||||
|
return $attrString;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFormScripts(string $componentId): string
|
||||||
|
{
|
||||||
|
return <<<SCRIPT
|
||||||
|
<script>
|
||||||
|
(function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Conditional logic
|
||||||
|
$('[data-conditional-field]').each(function() {
|
||||||
|
const field = $(this);
|
||||||
|
const targetFieldName = field.data('conditional-field');
|
||||||
|
const targetValue = field.data('conditional-value');
|
||||||
|
const targetField = $('[name*="[' + targetFieldName + ']"]');
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
let currentValue;
|
||||||
|
if (targetField.is(':checkbox')) {
|
||||||
|
currentValue = targetField.is(':checked') ? 'true' : 'false';
|
||||||
|
} else {
|
||||||
|
currentValue = targetField.val();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue === targetValue) {
|
||||||
|
field.closest('.roi-form-field').show();
|
||||||
|
} else {
|
||||||
|
field.closest('.roi-form-field').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetField.on('change', updateVisibility);
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media upload
|
||||||
|
$('.roi-media-upload-btn').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const button = $(this);
|
||||||
|
const targetId = button.data('target');
|
||||||
|
const targetField = $('#' + targetId);
|
||||||
|
|
||||||
|
const mediaUploader = wp.media({
|
||||||
|
title: 'Seleccionar imagen',
|
||||||
|
button: { text: 'Usar esta imagen' },
|
||||||
|
multiple: false
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaUploader.on('select', function() {
|
||||||
|
const attachment = mediaUploader.state().get('selection').first().toJSON();
|
||||||
|
targetField.val(attachment.url);
|
||||||
|
|
||||||
|
const preview = targetField.closest('.roi-form-field-media').find('img');
|
||||||
|
if (preview.length) {
|
||||||
|
preview.attr('src', attachment.url);
|
||||||
|
} else {
|
||||||
|
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 100px; height: auto;"></div>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaUploader.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color picker sync
|
||||||
|
$('.form-control-color').on('change', function() {
|
||||||
|
$(this).next('input[type="text"]').val($(this).val());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-update preview
|
||||||
|
$('.roi-form-field input, .roi-form-field select, .roi-form-field textarea').on('change keyup', function() {
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
// Aquí iría la lógica para actualizar la vista previa en tiempo real
|
||||||
|
console.log('Preview updated');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(jQuery);
|
||||||
|
</script>
|
||||||
|
SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComponentId(): string
|
||||||
|
{
|
||||||
|
return $this->componentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ROITheme\TopNotificationBar\Infrastructure\Presentation\Public;
|
||||||
|
|
||||||
|
use ROITheme\Component\Domain\Component;
|
||||||
|
use ROITheme\Component\Domain\RendererInterface;
|
||||||
|
|
||||||
|
final class TopNotificationBarRenderer implements RendererInterface
|
||||||
|
{
|
||||||
|
public function render(Component $component): string
|
||||||
|
{
|
||||||
|
$data = $component->getData();
|
||||||
|
|
||||||
|
// Verificar si el componente está habilitado
|
||||||
|
if (!$this->isEnabled($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si debe mostrarse en la página actual
|
||||||
|
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si el usuario ha cerrado la barra (si es dismissible)
|
||||||
|
if ($this->isDismissed($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = $this->buildClasses($data);
|
||||||
|
$styles = $this->buildInlineStyles($data);
|
||||||
|
$content = $this->buildContent($data);
|
||||||
|
|
||||||
|
$html = sprintf(
|
||||||
|
'<div class="%s"%s%s>%s</div>',
|
||||||
|
esc_attr($classes),
|
||||||
|
$styles ? ' style="' . esc_attr($styles) . '"' : '',
|
||||||
|
$this->buildDataAttributes($data),
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agregar script si es dismissible
|
||||||
|
if ($this->isDismissible($data)) {
|
||||||
|
$html .= $this->buildDismissScript($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar estilos de animación si está habilitada
|
||||||
|
if ($this->isAnimationEnabled($data)) {
|
||||||
|
$html .= $this->buildAnimationStyles($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_enabled']) &&
|
||||||
|
$data['visibility']['is_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowOnCurrentPage(array $data): bool
|
||||||
|
{
|
||||||
|
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||||
|
|
||||||
|
switch ($showOn) {
|
||||||
|
case 'all':
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'home':
|
||||||
|
return is_front_page();
|
||||||
|
|
||||||
|
case 'posts':
|
||||||
|
return is_single();
|
||||||
|
|
||||||
|
case 'pages':
|
||||||
|
return is_page();
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
|
||||||
|
$allowedIds = array_map('trim', explode(',', $pageIds));
|
||||||
|
return in_array((string)get_the_ID(), $allowedIds, true);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDismissed(array $data): bool
|
||||||
|
{
|
||||||
|
if (!$this->isDismissible($data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieName = 'roi_notification_bar_dismissed';
|
||||||
|
return isset($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDismissible(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['visibility']['is_dismissible']) &&
|
||||||
|
$data['visibility']['is_dismissible'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAnimationEnabled(array $data): bool
|
||||||
|
{
|
||||||
|
return isset($data['styles']['animation_enabled']) &&
|
||||||
|
$data['styles']['animation_enabled'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildClasses(array $data): string
|
||||||
|
{
|
||||||
|
$classes = ['top-notification-bar'];
|
||||||
|
|
||||||
|
// Ocultar en móvil si está configurado
|
||||||
|
if (isset($data['visibility']['hide_on_mobile']) && $data['visibility']['hide_on_mobile']) {
|
||||||
|
$classes[] = 'd-none';
|
||||||
|
$classes[] = 'd-md-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar clase de animación
|
||||||
|
if ($this->isAnimationEnabled($data)) {
|
||||||
|
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
|
||||||
|
$classes[] = 'roi-animated';
|
||||||
|
$classes[] = 'roi-' . $animationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInlineStyles(array $data): string
|
||||||
|
{
|
||||||
|
$styles = [];
|
||||||
|
|
||||||
|
// Color de fondo
|
||||||
|
if (!empty($data['styles']['background_color'])) {
|
||||||
|
$styles[] = 'background-color: ' . $data['styles']['background_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color de texto
|
||||||
|
if (!empty($data['styles']['text_color'])) {
|
||||||
|
$styles[] = 'color: ' . $data['styles']['text_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamaño de fuente
|
||||||
|
$fontSize = $data['styles']['font_size'] ?? 'small';
|
||||||
|
$fontSizes = [
|
||||||
|
'extra-small' => '0.75rem',
|
||||||
|
'small' => '0.875rem',
|
||||||
|
'normal' => '1rem',
|
||||||
|
'large' => '1.125rem'
|
||||||
|
];
|
||||||
|
if (isset($fontSizes[$fontSize])) {
|
||||||
|
$styles[] = 'font-size: ' . $fontSizes[$fontSize];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padding vertical
|
||||||
|
$padding = $data['styles']['padding_vertical'] ?? 'normal';
|
||||||
|
$paddings = [
|
||||||
|
'compact' => '0.5rem 0',
|
||||||
|
'normal' => '0.75rem 0',
|
||||||
|
'spacious' => '1rem 0'
|
||||||
|
];
|
||||||
|
if (isset($paddings[$padding])) {
|
||||||
|
$styles[] = 'padding: ' . $paddings[$padding];
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContent(array $data): string
|
||||||
|
{
|
||||||
|
$alignment = $data['styles']['text_alignment'] ?? 'center';
|
||||||
|
$alignmentClasses = [
|
||||||
|
'left' => 'justify-content-start',
|
||||||
|
'center' => 'justify-content-center',
|
||||||
|
'right' => 'justify-content-end'
|
||||||
|
];
|
||||||
|
$alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center';
|
||||||
|
|
||||||
|
$html = '<div class="container">';
|
||||||
|
$html .= sprintf('<div class="d-flex align-items-center %s">', esc_attr($alignmentClass));
|
||||||
|
|
||||||
|
// Ícono
|
||||||
|
$html .= $this->buildIcon($data);
|
||||||
|
|
||||||
|
// Texto del anuncio
|
||||||
|
$html .= $this->buildAnnouncementText($data);
|
||||||
|
|
||||||
|
// Enlace
|
||||||
|
$html .= $this->buildLink($data);
|
||||||
|
|
||||||
|
// Botón de cerrar (si es dismissible)
|
||||||
|
if ($this->isDismissible($data)) {
|
||||||
|
$html .= $this->buildDismissButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildIcon(array $data): string
|
||||||
|
{
|
||||||
|
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
|
||||||
|
|
||||||
|
switch ($iconType) {
|
||||||
|
case 'bootstrap':
|
||||||
|
$iconClass = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
|
||||||
|
// Asegurar que tenga el prefijo 'bi'
|
||||||
|
if (strpos($iconClass, 'bi-') !== 0) {
|
||||||
|
$iconClass = 'bi-' . $iconClass;
|
||||||
|
}
|
||||||
|
return sprintf('<i class="bi %s me-2"></i>', esc_attr($iconClass));
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
$iconUrl = $data['content']['custom_icon_url'] ?? '';
|
||||||
|
if (!empty($iconUrl)) {
|
||||||
|
return sprintf(
|
||||||
|
'<img src="%s" alt="Icon" class="me-2" style="width: 24px; height: 24px;">',
|
||||||
|
esc_url($iconUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
|
||||||
|
case 'none':
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAnnouncementText(array $data): string
|
||||||
|
{
|
||||||
|
$label = $data['content']['announcement_label'] ?? '';
|
||||||
|
$text = $data['content']['announcement_text'] ?? '';
|
||||||
|
|
||||||
|
if (empty($text)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<span>';
|
||||||
|
|
||||||
|
if (!empty($label)) {
|
||||||
|
$html .= sprintf('<strong>%s</strong> ', esc_html($label));
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= esc_html($text);
|
||||||
|
$html .= '</span>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLink(array $data): string
|
||||||
|
{
|
||||||
|
$linkEnabled = $data['content']['link_enabled'] ?? true;
|
||||||
|
|
||||||
|
if (!$linkEnabled) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkText = $data['content']['link_text'] ?? '';
|
||||||
|
$linkUrl = $data['content']['link_url'] ?? '#';
|
||||||
|
$linkTarget = $data['content']['link_target'] ?? '_self';
|
||||||
|
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
|
||||||
|
|
||||||
|
if (empty($linkText)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<a href="%s" target="%s" class="ms-2 text-decoration-underline" style="color: %s;">%s</a>',
|
||||||
|
esc_url($linkUrl),
|
||||||
|
esc_attr($linkTarget),
|
||||||
|
esc_attr($linkColor),
|
||||||
|
esc_html($linkText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDismissButton(): string
|
||||||
|
{
|
||||||
|
return '<button type="button" class="btn-close btn-close-white ms-3" aria-label="Cerrar" onclick="roiDismissNotificationBar()"></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDataAttributes(array $data): string
|
||||||
|
{
|
||||||
|
if (!$this->isDismissible($data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = $data['visibility']['dismissible_cookie_days'] ?? 7;
|
||||||
|
return sprintf(' data-dismissible-days="%d"', (int)$days);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDismissScript(array $data): string
|
||||||
|
{
|
||||||
|
$days = $data['visibility']['dismissible_cookie_days'] ?? 7;
|
||||||
|
|
||||||
|
return <<<SCRIPT
|
||||||
|
<script>
|
||||||
|
function roiDismissNotificationBar() {
|
||||||
|
const bar = document.querySelector('.top-notification-bar');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establecer cookie
|
||||||
|
const days = {$days};
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
|
const expires = "expires=" + date.toUTCString();
|
||||||
|
document.cookie = "roi_notification_bar_dismissed=1;" + expires + ";path=/";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAnimationStyles(array $data): string
|
||||||
|
{
|
||||||
|
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
|
||||||
|
|
||||||
|
$animations = [
|
||||||
|
'slide-down' => [
|
||||||
|
'keyframes' => '@keyframes roiSlideDown { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }',
|
||||||
|
'animation' => 'roiSlideDown 0.5s ease-out'
|
||||||
|
],
|
||||||
|
'fade-in' => [
|
||||||
|
'keyframes' => '@keyframes roiFadeIn { from { opacity: 0; } to { opacity: 1; } }',
|
||||||
|
'animation' => 'roiFadeIn 0.5s ease-out'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$anim = $animations[$animationType] ?? $animations['slide-down'];
|
||||||
|
|
||||||
|
return <<<STYLE
|
||||||
|
<style>
|
||||||
|
{$anim['keyframes']}
|
||||||
|
.top-notification-bar.roi-animated.roi-{$animationType} {
|
||||||
|
animation: {$anim['animation']};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
STYLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $componentType): bool
|
||||||
|
{
|
||||||
|
return $componentType === 'top_notification_bar';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user