35 Commits

Author SHA1 Message Date
FrankZamora
d6070099d1 fix(navbar): Increase dropdown max-height default to 500px
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:32:06 -06:00
FrankZamora
8a49b19d00 perf(fonts): Self-host Poppins fonts
- Add WOFF2 font files for Poppins (400, 500, 600, 700 weights)
- Create @font-face declarations in css-global-fonts.css
- Remove Google Fonts dependency from enqueue-scripts.php
- Remove preconnect hints for fonts.googleapis.com from seo.php

Benefits:
- Eliminates 36.2 kB external transfer from Google Fonts
- Improves GDPR compliance (no Google tracking)
- Reduces render-blocking requests
- Faster font loading from local server

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:26:16 -06:00
FrankZamora
9d14f38965 fix: Remove CSS 404 errors and invalid preloads
Remove enqueues of non-existent CSS files (render blocking 404s):
- Remove componente-footer-principal.css from roi_enqueue_header()
- Remove roi_enqueue_social_share_styles() (SocialShareRenderer generates CSS)
- Remove roi_enqueue_footer_contact_assets() (ContactFormRenderer generates CSS)

Fix preloads in performance.php:
- Remove inter-var.woff2 preloads (fonts don't exist, using Poppins)
- Fix fonts.css reference to css-global-fonts.css

Minor: Update contact-form default info_value_color

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:19:59 -06:00
FrankZamora
f35b60ed4e feat(admin): Add Google AdSense Auto Ads support to theme-settings
- Add adsense group to theme-settings.json schema (v1.2.0)
- Add adsense_publisher_id and adsense_auto_ads fields
- Add AdSense card UI in ThemeSettingsFormBuilder
- Add field mappings in ThemeSettingsFieldMapper
- Add renderAdSenseAutoAds() method in ThemeSettingsRenderer
- Inject AdSense script in wp_head when configured

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:06:42 -06:00
FrankZamora
7cc5f194e9 refactor(validators): Add support for injection components
Injection components (like theme-settings) are special components that:
- Don't render visual HTML (inject code into wp_head/wp_footer)
- Don't need visibility group (always enabled)
- Don't need CSSGeneratorInterface (output user-provided CSS)
- Don't need getVisibilityClasses (not responsive visual elements)

- Update Phase01Validator to skip visibility check for injection components
- Update Phase03Validator to skip CSS/visibility validations for injection components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 21:59:04 -06:00
FrankZamora
6dc052afa6 chore: Remove legacy theme options files
BREAKING: Remove deprecated files replaced by Clean Architecture

- Remove Inc/theme-options-helpers.php (replaced by roi_get_component_setting)
- Remove Inc/theme-settings.php (replaced by ThemeSettingsInjector)
- Remove Inc/customizer-fonts.php (fonts now in navbar component)
- Remove Inc/toc.php (replaced by TableOfContentsRenderer)
- Update functions.php - remove require_once for deleted files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 21:58:54 -06:00
FrankZamora
8878afe168 refactor: Remove legacy roi_get_option() calls from Inc/ files
- Clean Inc/adsense-delay.php
- Clean Inc/category-badge.php
- Clean Inc/enqueue-scripts.php
- Clean Inc/featured-image.php
- Clean Inc/social-share.php
- Clean sidebar.php - use roi_render_component('table-of-contents')
- Add roi_get_component_setting() helper to functions-addon.php

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 21:58:44 -06:00
FrankZamora
7a8daa72c6 chore: Add migration script for legacy theme options
Script to migrate data from wp_options (roi_theme_settings)
to normalized table wp_roi_theme_component_settings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 21:58:28 -06:00
FrankZamora
f52a395e0d feat(admin): Add theme-settings component for global configurations
- Add Schemas/theme-settings.json with analytics and custom_code groups
- Add ThemeSettingsFormBuilder for Admin Panel UI
- Add ThemeSettingsFieldMapper for AJAX field mapping
- Add ThemeSettingsRenderer for injecting GA/CSS/JS
- Add ThemeSettingsInjector for wp_head/wp_footer hooks
- Register component in AdminDashboardRenderer::getComponents()
- Register FieldMapper in FieldMapperProvider

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 21:58:14 -06:00
FrankZamora
6e75527157 chore: Remove CTA A/B Testing legacy system
This feature was not part of the current development phase.

Deleted files:
- Inc/cta-ab-testing.php (A/B testing implementation)
- Inc/customizer-cta.php (WordPress Customizer settings + Google Analytics)
- TemplateParts/content-cta.php (CTA template part)
- Assets/Js/cta-tracking.js (GA4 tracking script)

Modified files:
- functions.php: Remove require_once for deleted files
- functions-addon.php: Remove CTA loading references
- Inc/enqueue-scripts.php: Remove roi_enqueue_cta_assets() function

Note: CTA components in Admin Panel (CtaBoxSidebar, CtaLetsTalk, CtaPost)
are NOT affected - they are part of the Clean Architecture system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 20:22:07 -06:00
FrankZamora
4f11c2c312 refactor(admin): Migrate AdminAjaxHandler to Clean Architecture
- Move AdminAjaxHandler to Admin/Shared/Infrastructure/Api/Wordpress/
- Create FieldMapperInterface for decentralized field mapping
- Create FieldMapperRegistry for module discovery
- Create FieldMapperProvider for auto-registration of 12 mappers
- Add FieldMappers for all components:
  - ContactFormFieldMapper (46 fields)
  - CtaBoxSidebarFieldMapper (32 fields)
  - CtaLetsTalkFieldMapper
  - CtaPostFieldMapper
  - FeaturedImageFieldMapper (15 fields)
  - FooterFieldMapper (31 fields)
  - HeroFieldMapper
  - NavbarFieldMapper
  - RelatedPostFieldMapper (34 fields)
  - SocialShareFieldMapper
  - TableOfContentsFieldMapper
  - TopNotificationBarFieldMapper (17 fields)
- Update functions.php bootstrap with FieldMapperProvider
- AdminAjaxHandler reduced from ~700 to 145 lines
- Follows SRP, OCP, DIP principles

BACKUP BEFORE: Removing CTA A/B Testing legacy system

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 20:18:55 -06:00
FrankZamora
1a4d9d8c08 fix: Corregir namespace DI → Di en DIContainer.php
- Namespace corregido de `DI` (mayúsculas) a `Di` (PascalCase)
- Consistencia con PSR-4 y convenciones del proyecto
- Detectado durante revisión del plan de refactorización

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 18:26:36 -06:00
FrankZamora
71cfd54166 fix: Corregir case de namespaces para compatibilidad Linux/PSR-4
Cambios realizados:
- \API\ → \Api\ (4 archivos)
- \WordPress → \Wordpress (12 archivos)
- \DI\ → \Di\ (4 archivos)

Los namespaces ahora coinciden exactamente con la estructura
de carpetas (Api/, Wordpress/, Di/) para garantizar
compatibilidad con sistemas case-sensitive (Linux/producción)
y cumplimiento de PSR-4.

Archivos corregidos: 16

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 18:12:05 -06:00
FrankZamora
4c807e1cf2 Backup pre-corrección namespaces: mejoras schemas y componentes
Cambios incluidos:
- Actualización de copy/textos en 7 schemas JSON
- Mejoras en AdminAjaxHandler con mapeos adicionales
- Refactorización de FormBuilders y Renderers
- Correcciones en dashboard admin JS
- Nuevo ContactFormRenderer funcional

NOTA: Este commit sirve como respaldo antes de corregir
inconsistencias de case en namespaces (API→Api, WordPress→Wordpress)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 17:59:01 -06:00
FrankZamora
0846a3bf03 Migración completa a Clean Architecture con componentes funcionales
- Reorganización de estructura: Admin/, Public/, Shared/, Schemas/
- 12 componentes migrados: TopNotificationBar, Navbar, CtaLetsTalk, Hero,
  FeaturedImage, TableOfContents, CtaBoxSidebar, SocialShare, CtaPost,
  RelatedPost, ContactForm, Footer
- Panel de administración con tabs Bootstrap 5 funcionales
- Schemas JSON para configuración de componentes
- Renderers dinámicos con CSSGeneratorService (cero CSS hardcodeado)
- FormBuilders para UI admin con Design System consistente
- Fix: Bootstrap JS cargado en header para tabs funcionales
- Fix: buildTextInput maneja valores mixed (bool/string)
- Eliminación de estructura legacy (src/, admin/, assets/css/componente-*)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 21:20:06 -06:00
FrankZamora
90de6df77c 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>
2025-11-19 16:34:49 -06:00
FrankZamora
677fbd4368 Fase-00: Estructura Clean Architecture Context-First creada
- Creada estructura shared/ con Domain, Application, Infrastructure
- Documentación completa de arquitectura (5 READMEs, ~6150 líneas)
- Archivos .gitkeep para preservar estructura en Git
- Contextos admin/ y public/ documentados

Estructura shared/:
- Domain/ (ValueObjects, Exceptions, Contracts)
- Application/ (Contracts, Services)
- Infrastructure/ (Services, Traits)

Documentación incluye:
- Principios de Clean Architecture
- Reglas de dependencia
- Ejemplos de código
- Guías de testing
- Mejores prácticas

Preparación completa para implementación en Fase-1.
2025-11-18 23:36:06 -06:00
FrankZamora
42edfab50d Fase-00: Configuración inicial del proyecto
- Configuración de Composer con PSR-4 en _testing-suite/
- Configuración de PHPUnit en _testing-suite/phpunit.xml
- Configuración de PHPCS en _testing-suite/phpcs.xml
- WordPress Test Suite integrado en bootstrap-integration.php
- Scripts de backup automatizado (backup-database.php, backup-files.bat)
- Procedimientos de rollback documentados (RESTORE-PROCEDURE.md)
- Estrategia de Git branching documentada (GIT-BRANCHING-STRATEGY.md)
- .gitignore actualizado para excluir _testing-suite/ y _planeacion/
- Limpieza de estructura anterior de Clean Architecture
- Documentación completa en _planeacion/roi-theme/_MIGRACION-CLEAN-ARCHITECTURE/Fase-00/

Archivos de configuración movidos a _testing-suite/:
- composer.json (PSR-4 autoloading, dev dependencies)
- phpunit.xml (3 suites: Unit, Integration, E2E)
- phpcs.xml (WordPress Coding Standards)
- bootstrap-unit.php y bootstrap-integration.php

Sistema de backup implementado con 4 mejoras críticas:
- Password protegido con --defaults-file
- Verificación de espacio en disco
- Lock files para prevenir ejecuciones concurrentes
- Detección automática de rutas

Procedimientos de rollback documentados:
- Rollback de base de datos (5-10 min)
- Rollback de archivos (10-15 min)
- Rollback de Git (5 min)
- Rollback completo (20-30 min)

Preparación del entorno completa. Listo para comenzar Fase-1.
2025-11-18 23:26:28 -06:00
FrankZamora
e34fd28df7 Fase 2: Migración de Base de Datos - Clean Architecture
COMPLETADO: Fase 2 de la migración a Clean Architecture + POO

## DatabaseMigrator
- ✓ Clase DatabaseMigrator con estrategia completa de migración
- ✓ Creación de tablas v2 con nueva estructura (config_group)
- ✓ Migración de datos con transformación automática
- ✓ Validación de integridad de datos migrados
- ✓ Swap seguro de tablas (legacy → _backup, v2 → producción)
- ✓ Rollback automático en caso de error
- ✓ Logging detallado de todas las operaciones

## Transformaciones de BD
- ✓ Nueva columna config_group (visibility, content, styles, general)
- ✓ Renombrado: version → schema_version
- ✓ UNIQUE KEY actualizada: (component_name, config_group, config_key)
- ✓ Nuevos índices: idx_group, idx_schema_version
- ✓ Timestamps con DEFAULT CURRENT_TIMESTAMP

## MigrationCommand (WP-CLI)
- ✓ Comando: wp roi-theme migrate
- ✓ Opción --dry-run para simulación segura
- ✓ Comando: wp roi-theme cleanup-backup
- ✓ Output formateado y detallado
- ✓ Confirmación para operaciones destructivas
- ✓ Estadísticas de migración completas

## Tests de Integración
- ✓ 6 tests de integración implementados
- ✓ Test: Creación de tablas v2
- ✓ Test: Preservación de cantidad de registros
- ✓ Test: Inferencia correcta de grupos
- ✓ Test: Creación de backup
- ✓ Test: Rollback en error
- ✓ Test: Cleanup de backup

## Heurística de Inferencia de Grupos
- enabled, visible_* → visibility
- message_*, cta_*, title_* → content
- *_color, *_height, *_width, *_size, *_font → styles
- Resto → general

## Integración
- ✓ Comando WP-CLI registrado en functions.php
- ✓ Autoloader actualizado
- ✓ Strict types en todos los archivos
- ✓ PHPDoc completo

## Validación
- ✓ Script validate-phase-2.php (26/26 checks pasados)
- ✓ Sintaxis PHP válida en todos los archivos
- ✓ 100% de validaciones exitosas

## Seguridad
- ✓ Backup automático de tablas legacy (_backup)
- ✓ Rollback automático si falla validación
- ✓ Validación de integridad antes de swap
- ✓ Logging completo para auditoría

IMPORTANTE: La migración está lista pero NO ejecutada. Ejecutar con:
1. wp db export backup-antes-migracion.sql
2. wp roi-theme migrate --dry-run
3. wp roi-theme migrate

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 14:39:29 -06:00
FrankZamora
de5fff4f5c Fase 1: Estructura Base y DI Container - Clean Architecture
COMPLETADO: Fase 1 de la migración a Clean Architecture + POO

## Estructura de Carpetas
- ✓ Estructura completa de 4 capas (Domain, Application, Infrastructure, Presentation)
- ✓ Carpetas de Use Cases (SaveComponent, GetComponent, DeleteComponent, SyncSchema)
- ✓ Estructura de tests (Unit, Integration, E2E)
- ✓ Carpetas de schemas y templates

## Composer y Autoloading
- ✓ PSR-4 autoloading configurado para ROITheme namespace
- ✓ Autoloader optimizado regenerado

## DI Container
- ✓ DIContainer implementado con patrón Singleton
- ✓ Métodos set(), get(), has() para gestión de servicios
- ✓ Getters específicos para ComponentRepository, ValidationService, CacheService
- ✓ Placeholders que serán implementados en Fase 5
- ✓ Prevención de clonación y deserialización

## Interfaces
- ✓ ComponentRepositoryInterface (Domain)
- ✓ ValidationServiceInterface (Application)
- ✓ CacheServiceInterface (Application)
- ✓ Component entity placeholder (Domain)

## Bootstrap
- ✓ functions.php actualizado con carga de Composer autoloader
- ✓ Inicialización del DIContainer
- ✓ Helper function roi_container() disponible globalmente

## Tests
- ✓ 10 tests unitarios para DIContainer (100% cobertura)
- ✓ Total: 13 tests unitarios, 28 assertions
- ✓ Suite de tests pasando correctamente

## Validación
- ✓ Script de validación automatizado (48/48 checks pasados)
- ✓ 100% de validaciones exitosas

La arquitectura base está lista para la Fase 2.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 13:48:24 -06:00
FrankZamora
b782ebceee Fase 0: Configuración inicial del proyecto
- Configuración de Composer con PSR-4
- Configuración de PHPUnit
- Configuración de PHPCS
- Scripts de backup y rollback
- Estructura de carpetas inicial
- Documentación de procedimientos
2025-11-17 12:01:51 -06:00
FrankZamora
a6578f4973 fix: eliminar submenú duplicado Apus Theme
- Agregar remove_submenu_page() para eliminar entrada duplicada
- WordPress crea automáticamente primer submenú con nombre del padre
- Ahora solo aparece submenú 'Theme Options'
2025-11-13 23:20:15 -06:00
FrankZamora
77dd809e8c fix: corregir estructura de menú para mostrar submenú Theme Options
- Cambiar menu slug principal a 'apus-theme-menu' (contenedor)
- Menú principal sin callback (solo contenedor del submenú)
- Submenú 'Theme Options' con slug 'apus-theme-settings' (página real)
- Actualizar hook en enqueue_assets a 'apus-theme_page_apus-theme-settings'
- Limpiar logs de debug

Ahora el menú 'Apus Theme' muestra correctamente el submenú desplegable con 'Theme Options'
2025-11-13 23:18:00 -06:00
FrankZamora
60b3992ca5 fix: desactivar carga de sistema viejo de theme options en functions.php
- Comentar carga de admin/theme-options/theme-options.php
- Comentar carga de admin/theme-options/options-api.php
- El nuevo Admin Panel (admin/init.php) ya se carga en línea 272
- Esto elimina conflicto entre sistema viejo y nuevo
2025-11-13 23:06:57 -06:00
FrankZamora
49b923230f fix: desactivar registros duplicados de menú en Apariencia
- Comentar add_theme_page() en inc/admin/theme-options.php
- Comentar add_theme_page() en admin/theme-options/theme-options.php
- Estos archivos viejos estaban registrando 'Theme Options' en menú Apariencia
- Ahora solo se usa el nuevo registro en admin/includes/class-admin-menu.php
- Elimina conflicto que mostraba el menú en dos lugares
2025-11-13 23:05:17 -06:00
FrankZamora
9e29410c0d fix: corregir hook name para menú de nivel superior
- Cambiar de 'appearance_page_apus-theme-settings' a 'toplevel_page_apus-theme-settings'
- Necesario para que assets se carguen correctamente en menú de nivel superior
- Sin esto, el CSS/JS no se cargaba en la página de configuración
2025-11-13 23:01:46 -06:00
FrankZamora
f0989f4fb0 docs: crear documentación completa de arquitectura de datos
- Diagrama de arquitectura final implementada
- Estructura de tablas (defaults y components)
- Flujo de datos completo (escritura/lectura)
- Decisión arquitectónica: Opción A (wp_options para personalizaciones)
- Responsabilidades de cada clase
- Estado actual y próximos pasos
- Checklist de verificación

Completa documentación requerida por FASE 6 del plan
2025-11-13 22:59:27 -06:00
FrankZamora
3947e36c98 feat: mover menú Apus Theme a nivel superior en sidebar de WordPress
- Cambiar de add_theme_page() a add_menu_page()
- Posición 61 (después de Appearance, antes de Plugins)
- Ícono dashicons-admin-generic
- Menú ahora aparece al mismo nivel que Dashboard, Settings, etc.
- NO dentro del menú Apariencia

Completa PASO 2.0 del PLAN-PREPARACION-TEMA-BD.md
2025-11-13 22:57:54 -06:00
FrankZamora
03c97d31d3 feat: mejorar DB Manager para soportar ambas tablas (components y defaults)
- Agregar constante TABLE_DEFAULTS para tabla de valores por defecto
- Modificar get_table_name() para aceptar parámetro table_type
- Actualizar create_tables() para crear ambas tablas con estructura idéntica
- Modificar todos los métodos (get_config, save_config, delete_config, list_components) para aceptar table_type
- Actualizar Settings Manager get_defaults() para leer desde tabla defaults usando DB Manager
- Mantener compatibilidad hacia atrás con valor por defecto 'components'

Arquitectura final:
- wp_apus_theme_components_defaults = Valores por defecto del tema (escritura algoritmo)
- wp_apus_theme_components = Personalizaciones del usuario (escritura admin panel)
- Una sola clase (APUS_DB_Manager) maneja ambas tablas con misma estructura
2025-11-13 22:57:04 -06:00
FrankZamora
e94b274ed0 docs: corregir posición del menú Apus Theme en plan
- Cambiar posición de 59 a 61
- Aclarar que debe estar AL MISMO NIVEL que Settings, Dashboard, etc.
- NO debe estar dentro de Settings ni ningún otro menú
- Agregar tabla de posiciones de menús WordPress
- Posición 61 = entre Appearance (60) y Plugins (65)
2025-11-13 22:38:01 -06:00
FrankZamora
883853bc5c docs: agregar plan de preparación del tema para BD
- Plan de 6 fases para preparar tema antes de modificar algoritmo
- FASE 1: Análisis de arquitectura actual
- FASE 2: Corregir ubicación del menú admin (nuevo paso agregado)
  * Cambiar add_theme_page() a add_menu_page()
  * Mover 'Apus Theme Options' a menú propio en sidebar
- FASE 3: Crear Defaults Manager
- FASE 4: Decisión sobre personalizaciones
- FASE 5: Testing y validación
- FASE 6: Documentación

Plan ejecutable paso a paso antes de tocar algoritmo.
2025-11-13 22:36:34 -06:00
FrankZamora
1c6b184e94 fix: restaurar Top Notification Bar en header.php
- Agregado Top Bar hardcodeado como los demás componentes (navbar, let's talk)
- HTML copiado del template original (líneas 57-80)
- Mantiene el patrón del tema: componentes estáticos en templates
- Top Bar ahora visible nuevamente en el sitio
2025-11-13 22:23:25 -06:00
FrankZamora
8a99f184bf fix: eliminar carga de sanitizers eliminados en init.php
ERROR CRÍTICO corregido:
- El sitio estaba caído porque init.php intentaba cargar sanitizers eliminados
- Eliminadas líneas 30-36 que cargaban class-topbar-sanitizer.php y otros
- Sitio ahora funcional nuevamente

Ref: admin/init.php línea 33
2025-11-13 22:18:45 -06:00
FrankZamora
3ad2413e7a feat(db): crear tabla wp_apus_theme_components_defaults
- Tabla para almacenar valores por defecto de componentes
- Estructura con 8 columnas: id, component_name, config_key, config_value,
  data_type, version, created_at, updated_at
- Índices optimizados para búsquedas rápidas
- UNIQUE constraint en (component_name, config_key) para evitar duplicados
- Tabla ejecutada y verificada en base de datos preciosunitarios_wp

Esta tabla será la ÚNICA fuente de verdad para defaults de componentes.
El algoritmo consultará esta tabla en lugar de tener defaults hardcodeados.

Ref: PROBLEMA-DEFAULTS-HARDCODEADOS-ALGORITMO.md (Líneas 418-437)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 22:16:44 -06:00
FrankZamora
4818d90386 FASE 1 COMPLETADA: Limpieza de defaults hardcodeados
Eliminados todos los componentes incorrectos y defaults duplicados
preparando el sistema para la implementación correcta con tabla de BD.

ARCHIVOS ELIMINADOS (11 archivos):
- admin/assets/js/component-navbar.js
- admin/assets/css/component-navbar.css
- admin/components/component-top-bar.php
- admin/components/component-navbar.php
- admin/components/component-hero-section.php
- admin/components/component-lets-talk-button.php
- admin/includes/sanitizers/class-topbar-sanitizer.php
- admin/includes/sanitizers/class-navbar-sanitizer.php
- admin/includes/sanitizers/class-herosection-sanitizer.php
- admin/includes/sanitizers/class-letstalkbutton-sanitizer.php
- template-parts/navbar-configurable.php

ARCHIVOS MODIFICADOS (6 archivos):
- admin/includes/class-admin-menu.php: Eliminados enqueues de componentes
- admin/includes/class-settings-manager.php: Limpiados get_defaults() y sanitize_settings()
- admin/includes/class-validator.php: Eliminado validate_top_bar()
- admin/pages/main.php: Reducido de 521 a 37 líneas (93%)
- admin/assets/js/admin-app.js: Reducido de 431 a 219 líneas (49%)
- header.php: Eliminado código de Top Bar (92 líneas)

BASE DE DATOS:
- Eliminada opción 'apus_theme_settings' de wp_options

RESUMEN:
- 11 archivos eliminados
- 6 archivos limpiados
- 1 opción de BD eliminada
- Todos los defaults hardcodeados eliminados
- Sistema preparado para FASE 2 (crear tabla de defaults)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 22:13:23 -06:00
287 changed files with 30899 additions and 20176 deletions

9
.gitignore vendored
View File

@@ -42,6 +42,11 @@ npm-debug.log
# Composer (si hay dependencias PHP)
vendor/
composer.lock
# PHPUnit
.phpunit.result.cache
/tests/_output/
# Environment files
.env
@@ -65,7 +70,11 @@ vendor/
# Planning and documentation
_planeacion/
# Testing infrastructure (composer, phpunit, phpcs configs and dependencies)
_testing-suite/
# Claude Code tools
.playwright-mcp/
.serena/
.claude/
nul

18
404.php
View File

@@ -7,7 +7,7 @@
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#404-not-found
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -23,7 +23,7 @@ get_header();
<!-- Error Header -->
<header class="page-header">
<h1 id="error-404-title" class="page-title">
<?php esc_html_e( '404 - Page Not Found', 'apus-theme' ); ?>
<?php esc_html_e( '404 - Page Not Found', 'roi-theme' ); ?>
</h1>
</header><!-- .page-header -->
@@ -31,25 +31,25 @@ get_header();
<div class="page-content">
<p class="error-message">
<?php esc_html_e( 'Oops! The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.', 'apus-theme' ); ?>
<?php esc_html_e( 'Oops! The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.', 'roi-theme' ); ?>
</p>
<!-- Helpful Actions -->
<div class="error-actions">
<h2><?php esc_html_e( 'What can you do?', 'apus-theme' ); ?></h2>
<h2><?php esc_html_e( 'What can you do?', 'roi-theme' ); ?></h2>
<ul class="error-suggestions" role="list">
<li>
<a href="<?php echo esc_url( home_url( '/' ) ); ?>">
<?php esc_html_e( 'Go to the homepage', 'apus-theme' ); ?>
<?php esc_html_e( 'Go to the homepage', 'roi-theme' ); ?>
</a>
</li>
<li>
<?php esc_html_e( 'Check the URL for typos', 'apus-theme' ); ?>
<?php esc_html_e( 'Check the URL for typos', 'roi-theme' ); ?>
</li>
<li>
<?php esc_html_e( 'Use the navigation menu above', 'apus-theme' ); ?>
<?php esc_html_e( 'Use the navigation menu above', 'roi-theme' ); ?>
</li>
</ul>
@@ -65,7 +65,7 @@ get_header();
if ( ! empty( $recent_posts ) ) :
?>
<div class="recent-posts-section">
<h3><?php esc_html_e( 'Recent Posts', 'apus-theme' ); ?></h3>
<h3><?php esc_html_e( 'Recent Posts', 'roi-theme' ); ?></h3>
<ul class="recent-posts-list" role="list">
<?php foreach ( $recent_posts as $recent ) : ?>
<li>
@@ -95,7 +95,7 @@ get_header();
if ( ! empty( $categories ) ) :
?>
<div class="categories-section">
<h3><?php esc_html_e( 'Browse by Category', 'apus-theme' ); ?></h3>
<h3><?php esc_html_e( 'Browse by Category', 'roi-theme' ); ?></h3>
<ul class="categories-list" role="list">
<?php foreach ( $categories as $category ) : ?>
<li>

0
Admin/.gitkeep Normal file
View File

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Application\UseCases;
use ROITheme\Admin\Domain\Contracts\DashboardRendererInterface;
/**
* Caso de uso para renderizar el dashboard del panel de administración
*
* Application - Orquestación sin lógica de negocio ni WordPress
*/
final class RenderDashboardUseCase
{
/**
* @param DashboardRendererInterface $renderer Renderizador del dashboard
*/
public function __construct(
private readonly DashboardRendererInterface $renderer
) {
}
/**
* Ejecuta el caso de uso
*
* @param string $viewType Tipo de vista a renderizar
* @return string HTML renderizado
* @throws \RuntimeException Si el renderer no soporta el tipo de vista
*/
public function execute(string $viewType = 'dashboard'): string
{
if (!$this->renderer->supports($viewType)) {
throw new \RuntimeException(
sprintf('Renderer does not support view type: %s', $viewType)
);
}
return $this->renderer->render();
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Contact Form
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class ContactFormFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'contact-form';
}
public function getFieldMapping(): array
{
return [
// Visibility
'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'contactFormShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
'contactFormSectionDescription' => ['group' => 'content', 'attribute' => 'section_description'],
'contactFormSubmitButtonText' => ['group' => 'content', 'attribute' => 'submit_button_text'],
'contactFormSubmitButtonIcon' => ['group' => 'content', 'attribute' => 'submit_button_icon'],
// Contact Info
'contactFormShowContactInfo' => ['group' => 'contact_info', 'attribute' => 'show_contact_info'],
'contactFormPhoneLabel' => ['group' => 'contact_info', 'attribute' => 'phone_label'],
'contactFormPhoneValue' => ['group' => 'contact_info', 'attribute' => 'phone_value'],
'contactFormEmailLabel' => ['group' => 'contact_info', 'attribute' => 'email_label'],
'contactFormEmailValue' => ['group' => 'contact_info', 'attribute' => 'email_value'],
'contactFormLocationLabel' => ['group' => 'contact_info', 'attribute' => 'location_label'],
'contactFormLocationValue' => ['group' => 'contact_info', 'attribute' => 'location_value'],
// Form Labels
'contactFormFullnamePlaceholder' => ['group' => 'form_labels', 'attribute' => 'fullname_placeholder'],
'contactFormCompanyPlaceholder' => ['group' => 'form_labels', 'attribute' => 'company_placeholder'],
'contactFormWhatsappPlaceholder' => ['group' => 'form_labels', 'attribute' => 'whatsapp_placeholder'],
'contactFormEmailPlaceholder' => ['group' => 'form_labels', 'attribute' => 'email_placeholder'],
'contactFormMessagePlaceholder' => ['group' => 'form_labels', 'attribute' => 'message_placeholder'],
// Integration
'contactFormWebhookUrl' => ['group' => 'integration', 'attribute' => 'webhook_url'],
'contactFormWebhookMethod' => ['group' => 'integration', 'attribute' => 'webhook_method'],
'contactFormIncludePageUrl' => ['group' => 'integration', 'attribute' => 'include_page_url'],
'contactFormIncludeTimestamp' => ['group' => 'integration', 'attribute' => 'include_timestamp'],
// Messages
'contactFormSuccessMessage' => ['group' => 'messages', 'attribute' => 'success_message'],
'contactFormErrorMessage' => ['group' => 'messages', 'attribute' => 'error_message'],
'contactFormSendingMessage' => ['group' => 'messages', 'attribute' => 'sending_message'],
'contactFormValidationRequired' => ['group' => 'messages', 'attribute' => 'validation_required'],
'contactFormValidationEmail' => ['group' => 'messages', 'attribute' => 'validation_email'],
// Colors
'contactFormSectionBgColor' => ['group' => 'colors', 'attribute' => 'section_bg_color'],
'contactFormTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'contactFormDescriptionColor' => ['group' => 'colors', 'attribute' => 'description_color'],
'contactFormIconColor' => ['group' => 'colors', 'attribute' => 'icon_color'],
'contactFormInfoLabelColor' => ['group' => 'colors', 'attribute' => 'info_label_color'],
'contactFormInfoValueColor' => ['group' => 'colors', 'attribute' => 'info_value_color'],
'contactFormInputBorderColor' => ['group' => 'colors', 'attribute' => 'input_border_color'],
'contactFormInputFocusBorder' => ['group' => 'colors', 'attribute' => 'input_focus_border'],
'contactFormButtonBgColor' => ['group' => 'colors', 'attribute' => 'button_bg_color'],
'contactFormButtonTextColor' => ['group' => 'colors', 'attribute' => 'button_text_color'],
'contactFormButtonHoverBg' => ['group' => 'colors', 'attribute' => 'button_hover_bg'],
'contactFormSuccessBgColor' => ['group' => 'colors', 'attribute' => 'success_bg_color'],
'contactFormSuccessTextColor' => ['group' => 'colors', 'attribute' => 'success_text_color'],
'contactFormErrorBgColor' => ['group' => 'colors', 'attribute' => 'error_bg_color'],
'contactFormErrorTextColor' => ['group' => 'colors', 'attribute' => 'error_text_color'],
// Spacing
'contactFormSectionPaddingY' => ['group' => 'spacing', 'attribute' => 'section_padding_y'],
'contactFormSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
'contactFormTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'contactFormDescriptionMarginBottom' => ['group' => 'spacing', 'attribute' => 'description_margin_bottom'],
'contactFormFormGap' => ['group' => 'spacing', 'attribute' => 'form_gap'],
// Visual Effects
'contactFormInputBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'input_border_radius'],
'contactFormButtonBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'button_border_radius'],
'contactFormButtonPadding' => ['group' => 'visual_effects', 'attribute' => 'button_padding'],
'contactFormTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
'contactFormTextareaRows' => ['group' => 'visual_effects', 'attribute' => 'textarea_rows'],
];
}
}

View File

@@ -0,0 +1,601 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para Contact Form
*
* RESPONSABILIDAD: Generar formulario de configuracion del Contact Form
*
* SEGURIDAD: El webhook_url se muestra como input type="password" para evitar
* que sea visible accidentalmente en pantalla compartida.
*
* @package ROITheme\Admin\ContactForm\Infrastructure\Ui
*/
final class ContactFormFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildContactInfoGroup($componentId);
$html .= $this->buildFormLabelsGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildIntegrationGroup($componentId);
$html .= $this->buildMessagesGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-envelope-paper me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Formulario de Contacto';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Seccion de contacto antes del footer con envio a webhook';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="contact-form">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('contactFormEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('contactFormShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="contactFormShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="contactFormShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
$sectionTitle = $this->renderer->getFieldValue($componentId, 'content', 'section_title', '¿Tienes alguna pregunta?');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormSectionTitle" class="form-label small mb-1 fw-semibold">Titulo de seccion</label>';
$html .= ' <input type="text" id="contactFormSectionTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionTitle) . '">';
$html .= ' </div>';
$sectionDescription = $this->renderer->getFieldValue($componentId, 'content', 'section_description', 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormSectionDescription" class="form-label small mb-1 fw-semibold">Descripcion</label>';
$html .= ' <textarea id="contactFormSectionDescription" class="form-control form-control-sm" rows="2">';
$html .= esc_textarea($sectionDescription);
$html .= '</textarea>';
$html .= ' </div>';
$submitButtonText = $this->renderer->getFieldValue($componentId, 'content', 'submit_button_text', 'Enviar Mensaje');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormSubmitButtonText" class="form-label small mb-1 fw-semibold">Texto boton enviar</label>';
$html .= ' <input type="text" id="contactFormSubmitButtonText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($submitButtonText) . '">';
$html .= ' </div>';
$submitButtonIcon = $this->renderer->getFieldValue($componentId, 'content', 'submit_button_icon', 'bi-send-fill');
$html .= ' <div class="mb-0">';
$html .= ' <label for="contactFormSubmitButtonIcon" class="form-label small mb-1 fw-semibold">Icono boton (Bootstrap Icons)</label>';
$html .= ' <input type="text" id="contactFormSubmitButtonIcon" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($submitButtonIcon) . '" placeholder="bi-send-fill">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContactInfoGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-person-lines-fill me-2" style="color: #FF8600;"></i>';
$html .= ' Info de Contacto';
$html .= ' </h5>';
$showContactInfo = $this->renderer->getFieldValue($componentId, 'contact_info', 'show_contact_info', true);
$html .= $this->buildSwitch('contactFormShowContactInfo', 'Mostrar info contacto', 'bi-eye', $showContactInfo);
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">Telefono</p>';
$phoneLabel = $this->renderer->getFieldValue($componentId, 'contact_info', 'phone_label', 'Teléfono');
$html .= ' <div class="mb-2">';
$html .= ' <input type="text" id="contactFormPhoneLabel" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($phoneLabel) . '" placeholder="Label">';
$html .= ' </div>';
$phoneValue = $this->renderer->getFieldValue($componentId, 'contact_info', 'phone_value', '+52 55 1234 5678');
$html .= ' <div class="mb-3">';
$html .= ' <input type="text" id="contactFormPhoneValue" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($phoneValue) . '" placeholder="Numero">';
$html .= ' </div>';
$html .= ' <p class="small fw-semibold mb-2">Email</p>';
$emailLabel = $this->renderer->getFieldValue($componentId, 'contact_info', 'email_label', 'Email');
$html .= ' <div class="mb-2">';
$html .= ' <input type="text" id="contactFormEmailLabel" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($emailLabel) . '" placeholder="Label">';
$html .= ' </div>';
$emailValue = $this->renderer->getFieldValue($componentId, 'contact_info', 'email_value', 'contacto@apumexico.com');
$html .= ' <div class="mb-3">';
$html .= ' <input type="email" id="contactFormEmailValue" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($emailValue) . '" placeholder="Direccion">';
$html .= ' </div>';
$html .= ' <p class="small fw-semibold mb-2">Ubicacion</p>';
$locationLabel = $this->renderer->getFieldValue($componentId, 'contact_info', 'location_label', 'Ubicación');
$html .= ' <div class="mb-2">';
$html .= ' <input type="text" id="contactFormLocationLabel" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($locationLabel) . '" placeholder="Label">';
$html .= ' </div>';
$locationValue = $this->renderer->getFieldValue($componentId, 'contact_info', 'location_value', 'Ciudad de México, México');
$html .= ' <div class="mb-0">';
$html .= ' <input type="text" id="contactFormLocationValue" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($locationValue) . '" placeholder="Direccion">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildFormLabelsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-input-cursor-text me-2" style="color: #FF8600;"></i>';
$html .= ' Labels del Formulario';
$html .= ' </h5>';
$fullnamePlaceholder = $this->renderer->getFieldValue($componentId, 'form_labels', 'fullname_placeholder', 'Nombre completo *');
$html .= ' <div class="mb-2">';
$html .= ' <label for="contactFormFullnamePlaceholder" class="form-label small mb-1 fw-semibold">Placeholder nombre</label>';
$html .= ' <input type="text" id="contactFormFullnamePlaceholder" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fullnamePlaceholder) . '">';
$html .= ' </div>';
$companyPlaceholder = $this->renderer->getFieldValue($componentId, 'form_labels', 'company_placeholder', 'Empresa');
$html .= ' <div class="mb-2">';
$html .= ' <label for="contactFormCompanyPlaceholder" class="form-label small mb-1 fw-semibold">Placeholder empresa</label>';
$html .= ' <input type="text" id="contactFormCompanyPlaceholder" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($companyPlaceholder) . '">';
$html .= ' </div>';
$whatsappPlaceholder = $this->renderer->getFieldValue($componentId, 'form_labels', 'whatsapp_placeholder', 'WhatsApp *');
$html .= ' <div class="mb-2">';
$html .= ' <label for="contactFormWhatsappPlaceholder" class="form-label small mb-1 fw-semibold">Placeholder WhatsApp</label>';
$html .= ' <input type="text" id="contactFormWhatsappPlaceholder" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($whatsappPlaceholder) . '">';
$html .= ' </div>';
$emailPlaceholder = $this->renderer->getFieldValue($componentId, 'form_labels', 'email_placeholder', 'Correo electrónico *');
$html .= ' <div class="mb-2">';
$html .= ' <label for="contactFormEmailPlaceholder" class="form-label small mb-1 fw-semibold">Placeholder email</label>';
$html .= ' <input type="text" id="contactFormEmailPlaceholder" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($emailPlaceholder) . '">';
$html .= ' </div>';
$messagePlaceholder = $this->renderer->getFieldValue($componentId, 'form_labels', 'message_placeholder', '¿En qué podemos ayudarte?');
$html .= ' <div class="mb-0">';
$html .= ' <label for="contactFormMessagePlaceholder" class="form-label small mb-1 fw-semibold">Placeholder mensaje</label>';
$html .= ' <input type="text" id="contactFormMessagePlaceholder" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($messagePlaceholder) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildIntegrationGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-link-45deg me-2" style="color: #FF8600;"></i>';
$html .= ' Integracion Webhook';
$html .= ' <span class="badge bg-warning text-dark ms-2">Privado</span>';
$html .= ' </h5>';
$html .= ' <div class="alert alert-info py-2 small mb-3">';
$html .= ' <i class="bi bi-shield-lock me-1"></i>';
$html .= ' El webhook URL nunca se expone en el frontend. Los datos se envian de forma segura desde el servidor.';
$html .= ' </div>';
$webhookUrl = $this->renderer->getFieldValue($componentId, 'integration', 'webhook_url', '');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormWebhookUrl" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-link me-1" style="color: #FF8600;"></i>';
$html .= ' URL del Webhook';
$html .= ' </label>';
$html .= ' <textarea id="contactFormWebhookUrl" class="form-control form-control-sm" rows="2" ';
$html .= ' placeholder="https://tu-webhook.com/endpoint">';
$html .= esc_textarea($webhookUrl);
$html .= '</textarea>';
$html .= ' <small class="text-muted">Deja vacio si no deseas enviar a un webhook externo.</small>';
$html .= ' </div>';
$webhookMethod = $this->renderer->getFieldValue($componentId, 'integration', 'webhook_method', 'POST');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormWebhookMethod" class="form-label small mb-1 fw-semibold">Metodo HTTP</label>';
$html .= ' <select id="contactFormWebhookMethod" class="form-select form-select-sm">';
$html .= ' <option value="POST"' . ($webhookMethod === 'POST' ? ' selected' : '') . '>POST</option>';
$html .= ' <option value="GET"' . ($webhookMethod === 'GET' ? ' selected' : '') . '>GET</option>';
$html .= ' </select>';
$html .= ' </div>';
$includePageUrl = $this->renderer->getFieldValue($componentId, 'integration', 'include_page_url', true);
$html .= $this->buildSwitch('contactFormIncludePageUrl', 'Incluir URL de pagina', 'bi-link', $includePageUrl);
$includeTimestamp = $this->renderer->getFieldValue($componentId, 'integration', 'include_timestamp', true);
$html .= $this->buildSwitch('contactFormIncludeTimestamp', 'Incluir timestamp', 'bi-clock', $includeTimestamp);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildMessagesGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-chat-quote me-2" style="color: #FF8600;"></i>';
$html .= ' Mensajes';
$html .= ' </h5>';
$successMessage = $this->renderer->getFieldValue($componentId, 'messages', 'success_message', '¡Gracias por contactarnos! Te responderemos pronto.');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormSuccessMessage" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-check-circle me-1 text-success"></i>';
$html .= ' Mensaje de exito';
$html .= ' </label>';
$html .= ' <textarea id="contactFormSuccessMessage" class="form-control form-control-sm" rows="2">';
$html .= esc_textarea($successMessage);
$html .= '</textarea>';
$html .= ' </div>';
$errorMessage = $this->renderer->getFieldValue($componentId, 'messages', 'error_message', 'Hubo un error al enviar el mensaje. Por favor intenta de nuevo.');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormErrorMessage" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-x-circle me-1 text-danger"></i>';
$html .= ' Mensaje de error';
$html .= ' </label>';
$html .= ' <textarea id="contactFormErrorMessage" class="form-control form-control-sm" rows="2">';
$html .= esc_textarea($errorMessage);
$html .= '</textarea>';
$html .= ' </div>';
$sendingMessage = $this->renderer->getFieldValue($componentId, 'messages', 'sending_message', 'Enviando...');
$html .= ' <div class="mb-3">';
$html .= ' <label for="contactFormSendingMessage" class="form-label small mb-1 fw-semibold">Mensaje enviando</label>';
$html .= ' <input type="text" id="contactFormSendingMessage" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sendingMessage) . '">';
$html .= ' </div>';
$validationRequired = $this->renderer->getFieldValue($componentId, 'messages', 'validation_required', 'Este campo es obligatorio');
$html .= ' <div class="mb-2">';
$html .= ' <label for="contactFormValidationRequired" class="form-label small mb-1 fw-semibold">Error campo requerido</label>';
$html .= ' <input type="text" id="contactFormValidationRequired" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($validationRequired) . '">';
$html .= ' </div>';
$validationEmail = $this->renderer->getFieldValue($componentId, 'messages', 'validation_email', 'Por favor ingresa un email válido');
$html .= ' <div class="mb-0">';
$html .= ' <label for="contactFormValidationEmail" class="form-label small mb-1 fw-semibold">Error email invalido</label>';
$html .= ' <input type="text" id="contactFormValidationEmail" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($validationEmail) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Seccion
$html .= ' <p class="small fw-semibold mb-2">Seccion</p>';
$html .= ' <div class="row g-2 mb-3">';
$sectionBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'section_bg_color', 'rgba(108, 117, 125, 0.25)');
$html .= $this->buildColorPicker('contactFormSectionBgColor', 'Fondo seccion', $sectionBgColor);
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#212529');
$html .= $this->buildColorPicker('contactFormTitleColor', 'Color titulo', $titleColor);
$html .= ' </div>';
// Boton
$html .= ' <p class="small fw-semibold mb-2">Boton</p>';
$html .= ' <div class="row g-2 mb-3">';
$buttonBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_bg_color', '#FF8600');
$html .= $this->buildColorPicker('contactFormButtonBgColor', 'Fondo boton', $buttonBgColor);
$buttonTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_text_color', '#ffffff');
$html .= $this->buildColorPicker('contactFormButtonTextColor', 'Texto boton', $buttonTextColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$buttonHoverBg = $this->renderer->getFieldValue($componentId, 'colors', 'button_hover_bg', '#e67a00');
$html .= $this->buildColorPicker('contactFormButtonHoverBg', 'Hover boton', $buttonHoverBg);
$iconColor = $this->renderer->getFieldValue($componentId, 'colors', 'icon_color', '#FF8600');
$html .= $this->buildColorPicker('contactFormIconColor', 'Iconos', $iconColor);
$html .= ' </div>';
// Mensajes
$html .= ' <p class="small fw-semibold mb-2">Mensajes</p>';
$html .= ' <div class="row g-2 mb-0">';
$successBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'success_bg_color', '#d1e7dd');
$html .= $this->buildColorPicker('contactFormSuccessBgColor', 'Fondo exito', $successBgColor);
$errorBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'error_bg_color', '#f8d7da');
$html .= $this->buildColorPicker('contactFormErrorBgColor', 'Fondo error', $errorBgColor);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$sectionPaddingY = $this->renderer->getFieldValue($componentId, 'spacing', 'section_padding_y', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormSectionPaddingY" class="form-label small mb-1 fw-semibold">Padding vertical</label>';
$html .= ' <input type="text" id="contactFormSectionPaddingY" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionPaddingY) . '">';
$html .= ' </div>';
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormSectionMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="contactFormSectionMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginTop) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '0.75rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="contactFormTitleMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
$html .= ' </div>';
$formGap = $this->renderer->getFieldValue($componentId, 'spacing', 'form_gap', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormFormGap" class="form-label small mb-1 fw-semibold">Espacio campos</label>';
$html .= ' <input type="text" id="contactFormFormGap" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($formGap) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$inputBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'input_border_radius', '6px');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormInputBorderRadius" class="form-label small mb-1 fw-semibold">Radio inputs</label>';
$html .= ' <input type="text" id="contactFormInputBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($inputBorderRadius) . '">';
$html .= ' </div>';
$buttonBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_border_radius', '6px');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormButtonBorderRadius" class="form-label small mb-1 fw-semibold">Radio boton</label>';
$html .= ' <input type="text" id="contactFormButtonBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonBorderRadius) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$buttonPadding = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_padding', '0.75rem 2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormButtonPadding" class="form-label small mb-1 fw-semibold">Padding boton</label>';
$html .= ' <input type="text" id="contactFormButtonPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonPadding) . '">';
$html .= ' </div>';
$transitionDuration = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="col-6">';
$html .= ' <label for="contactFormTransitionDuration" class="form-label small mb-1 fw-semibold">Duracion transicion</label>';
$html .= ' <input type="text" id="contactFormTransitionDuration" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transitionDuration) . '">';
$html .= ' </div>';
$html .= ' </div>';
$textareaRows = $this->renderer->getFieldValue($componentId, 'visual_effects', 'textarea_rows', '4');
$html .= ' <div class="mb-0">';
$html .= ' <label for="contactFormTextareaRows" class="form-label small mb-1 fw-semibold">Filas textarea</label>';
$html .= ' <input type="number" id="contactFormTextareaRows" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($textareaRows) . '" min="2" max="10">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
// Manejar colores rgba
$colorValue = $value;
if (strpos($value, 'rgba') === 0 || strpos($value, 'rgb') === 0) {
// Para rgba usamos un color aproximado en el picker
$colorValue = '#6c757d';
}
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($colorValue)
);
$html .= sprintf(
' <input type="text" class="form-control" id="%sText" value="%s" style="font-size: 0.75rem;">',
esc_attr($id),
esc_attr($value)
);
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para CTA Box Sidebar
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*
* UBICACION:
* - Dentro del modulo CtaBoxSidebar (autocontenido)
* - Eliminar modulo = eliminar mapper
*/
final class CtaBoxSidebarFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'cta-box-sidebar';
}
public function getFieldMapping(): array
{
return [
// Visibility
'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],
'ctaDescription' => ['group' => 'content', 'attribute' => 'description'],
'ctaButtonText' => ['group' => 'content', 'attribute' => 'button_text'],
'ctaButtonIcon' => ['group' => 'content', 'attribute' => 'button_icon'],
'ctaButtonAction' => ['group' => 'content', 'attribute' => 'button_action'],
'ctaButtonLink' => ['group' => 'content', 'attribute' => 'button_link'],
// Behavior
'ctaTextAlign' => ['group' => 'behavior', 'attribute' => 'text_align'],
// Typography
'ctaTitleFontSize' => ['group' => 'typography', 'attribute' => 'title_font_size'],
'ctaTitleFontWeight' => ['group' => 'typography', 'attribute' => 'title_font_weight'],
'ctaDescFontSize' => ['group' => 'typography', 'attribute' => 'description_font_size'],
'ctaButtonFontSize' => ['group' => 'typography', 'attribute' => 'button_font_size'],
'ctaButtonFontWeight' => ['group' => 'typography', 'attribute' => 'button_font_weight'],
// Colors
'ctaBackgroundColor' => ['group' => 'colors', 'attribute' => 'background_color'],
'ctaTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'ctaDescriptionColor' => ['group' => 'colors', 'attribute' => 'description_color'],
'ctaButtonBgColor' => ['group' => 'colors', 'attribute' => 'button_background_color'],
'ctaButtonTextColor' => ['group' => 'colors', 'attribute' => 'button_text_color'],
'ctaButtonHoverBg' => ['group' => 'colors', 'attribute' => 'button_hover_background'],
'ctaButtonHoverText' => ['group' => 'colors', 'attribute' => 'button_hover_text_color'],
// Spacing
'ctaContainerPadding' => ['group' => 'spacing', 'attribute' => 'container_padding'],
'ctaTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'ctaDescMarginBottom' => ['group' => 'spacing', 'attribute' => 'description_margin_bottom'],
'ctaButtonPadding' => ['group' => 'spacing', 'attribute' => 'button_padding'],
'ctaIconMarginRight' => ['group' => 'spacing', 'attribute' => 'icon_margin_right'],
// Visual Effects
'ctaBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'border_radius'],
'ctaButtonBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'button_border_radius'],
'ctaBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'box_shadow'],
'ctaTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
];
}
}

View File

@@ -0,0 +1,518 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para el CTA Box Sidebar
*
* Responsabilidad:
* - Generar HTML del formulario de configuracion
* - Usar Design System (Bootstrap 5)
* - Cargar valores desde BD via AdminDashboardRenderer
*
* @package ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui
*/
final class CtaBoxSidebarFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildBehaviorGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-megaphone me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de CTA Box Sidebar';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Caja de llamada a la accion en el sidebar';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="cta-box-sidebar">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
// is_enabled
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('ctaEnabled', 'Activar CTA box', 'bi-power', $enabled);
// show_on_desktop
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('ctaShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
// show_on_mobile
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="ctaShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// title
$title = $this->renderer->getFieldValue($componentId, 'content', 'title', '¿Listo para potenciar tus proyectos?');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaTitle" class="form-label small mb-1 fw-semibold">Titulo</label>';
$html .= ' <input type="text" id="ctaTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($title) . '">';
$html .= ' </div>';
// description
$description = $this->renderer->getFieldValue($componentId, 'content', 'description', 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaDescription" class="form-label small mb-1 fw-semibold">Descripcion</label>';
$html .= ' <textarea id="ctaDescription" class="form-control form-control-sm" rows="2">' . esc_textarea($description) . '</textarea>';
$html .= ' </div>';
// button_text
$buttonText = $this->renderer->getFieldValue($componentId, 'content', 'button_text', 'Solicitar Demo');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaButtonText" class="form-label small mb-1 fw-semibold">Texto del boton</label>';
$html .= ' <input type="text" id="ctaButtonText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonText) . '">';
$html .= ' </div>';
// button_icon
$buttonIcon = $this->renderer->getFieldValue($componentId, 'content', 'button_icon', 'bi bi-calendar-check');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaButtonIcon" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-stars me-1" style="color: #FF8600;"></i>';
$html .= ' Icono del boton';
$html .= ' </label>';
$html .= ' <input type="text" id="ctaButtonIcon" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonIcon) . '" placeholder="ej: bi bi-calendar-check">';
$html .= ' <small class="text-muted">Clase de Bootstrap Icons</small>';
$html .= ' </div>';
// button_action
$buttonAction = $this->renderer->getFieldValue($componentId, 'content', 'button_action', 'modal');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaButtonAction" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-cursor me-1" style="color: #FF8600;"></i>';
$html .= ' Accion del boton';
$html .= ' </label>';
$html .= ' <select id="ctaButtonAction" class="form-select form-select-sm">';
$html .= ' <option value="modal"' . ($buttonAction === 'modal' ? ' selected' : '') . '>Abrir modal</option>';
$html .= ' <option value="link"' . ($buttonAction === 'link' ? ' selected' : '') . '>Ir a URL</option>';
$html .= ' <option value="scroll"' . ($buttonAction === 'scroll' ? ' selected' : '') . '>Scroll a seccion</option>';
$html .= ' </select>';
$html .= ' </div>';
// button_link
$buttonLink = $this->renderer->getFieldValue($componentId, 'content', 'button_link', '#contactModal');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaButtonLink" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
$html .= ' URL/ID destino';
$html .= ' </label>';
$html .= ' <input type="text" id="ctaButtonLink" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonLink) . '" placeholder="ej: #contactModal o https://...">';
$html .= ' <small class="text-muted">Para modal usa #nombreModal, para scroll usa #idSeccion</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildBehaviorGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-sliders me-2" style="color: #FF8600;"></i>';
$html .= ' Comportamiento';
$html .= ' </h5>';
// text_align
$textAlign = $this->renderer->getFieldValue($componentId, 'behavior', 'text_align', 'center');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaTextAlign" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-text-center me-1" style="color: #FF8600;"></i>';
$html .= ' Alineacion del texto';
$html .= ' </label>';
$html .= ' <select id="ctaTextAlign" class="form-select form-select-sm">';
$html .= ' <option value="left"' . ($textAlign === 'left' ? ' selected' : '') . '>Izquierda</option>';
$html .= ' <option value="center"' . ($textAlign === 'center' ? ' selected' : '') . '>Centro</option>';
$html .= ' <option value="right"' . ($textAlign === 'right' ? ' selected' : '') . '>Derecha</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// title_font_size
$titleFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_size', '1.25rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaTitleFontSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="ctaTitleFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontSize) . '">';
$html .= ' </div>';
// title_font_weight
$titleFontWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_weight', '700');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaTitleFontWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="ctaTitleFontWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// description_font_size
$descFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'description_font_size', '0.9rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaDescFontSize" class="form-label small mb-1 fw-semibold">Tamano descripcion</label>';
$html .= ' <input type="text" id="ctaDescFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($descFontSize) . '">';
$html .= ' </div>';
// button_font_size
$buttonFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'button_font_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaButtonFontSize" class="form-label small mb-1 fw-semibold">Tamano boton</label>';
$html .= ' <input type="text" id="ctaButtonFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonFontSize) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// button_font_weight
$buttonFontWeight = $this->renderer->getFieldValue($componentId, 'typography', 'button_font_weight', '700');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaButtonFontWeight" class="form-label small mb-1 fw-semibold">Peso boton</label>';
$html .= ' <input type="text" id="ctaButtonFontWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonFontWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Colores principales
$html .= ' <p class="small fw-semibold mb-2">Contenedor</p>';
$html .= ' <div class="row g-2 mb-3">';
$bgColor = $this->renderer->getFieldValue($componentId, 'colors', 'background_color', '#FF8600');
$html .= $this->buildColorPicker('ctaBackgroundColor', 'Fondo', $bgColor);
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#ffffff');
$html .= $this->buildColorPicker('ctaTitleColor', 'Titulo', $titleColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$descColor = $this->renderer->getFieldValue($componentId, 'colors', 'description_color', 'rgba(255, 255, 255, 0.95)');
$html .= $this->buildColorPicker('ctaDescriptionColor', 'Descripcion', $descColor);
$html .= ' </div>';
// Colores del boton
$html .= ' <p class="small fw-semibold mb-2">Boton</p>';
$html .= ' <div class="row g-2 mb-3">';
$buttonBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_background_color', '#ffffff');
$html .= $this->buildColorPicker('ctaButtonBgColor', 'Fondo', $buttonBgColor);
$buttonTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_text_color', '#FF8600');
$html .= $this->buildColorPicker('ctaButtonTextColor', 'Texto', $buttonTextColor);
$html .= ' </div>';
// Colores hover
$html .= ' <p class="small fw-semibold mb-2">Boton Hover</p>';
$html .= ' <div class="row g-2 mb-0">';
$buttonHoverBg = $this->renderer->getFieldValue($componentId, 'colors', 'button_hover_background', '#0E2337');
$html .= $this->buildColorPicker('ctaButtonHoverBg', 'Fondo hover', $buttonHoverBg);
$buttonHoverText = $this->renderer->getFieldValue($componentId, 'colors', 'button_hover_text_color', '#ffffff');
$html .= $this->buildColorPicker('ctaButtonHoverText', 'Texto hover', $buttonHoverText);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// container_padding
$containerPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'container_padding', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaContainerPadding" class="form-label small mb-1 fw-semibold">Padding contenedor</label>';
$html .= ' <input type="text" id="ctaContainerPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($containerPadding) . '">';
$html .= ' </div>';
// title_margin_bottom
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="ctaTitleMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// description_margin_bottom
$descMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'description_margin_bottom', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaDescMarginBottom" class="form-label small mb-1 fw-semibold">Margen descripcion</label>';
$html .= ' <input type="text" id="ctaDescMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($descMarginBottom) . '">';
$html .= ' </div>';
// button_padding
$buttonPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'button_padding', '0.75rem 1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaButtonPadding" class="form-label small mb-1 fw-semibold">Padding boton</label>';
$html .= ' <input type="text" id="ctaButtonPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonPadding) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// icon_margin_right
$iconMarginRight = $this->renderer->getFieldValue($componentId, 'spacing', 'icon_margin_right', '0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaIconMarginRight" class="form-label small mb-1 fw-semibold">Margen icono</label>';
$html .= ' <input type="text" id="ctaIconMarginRight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($iconMarginRight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// border_radius
$borderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_radius', '8px');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaBorderRadius" class="form-label small mb-1 fw-semibold">Radio contenedor</label>';
$html .= ' <input type="text" id="ctaBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '">';
$html .= ' </div>';
// button_border_radius
$buttonBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_border_radius', '8px');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaButtonBorderRadius" class="form-label small mb-1 fw-semibold">Radio boton</label>';
$html .= ' <input type="text" id="ctaButtonBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonBorderRadius) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// box_shadow
$boxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'box_shadow', '0 4px 12px rgba(255, 133, 0, 0.2)');
$html .= ' <div class="col-12">';
$html .= ' <label for="ctaBoxShadow" class="form-label small mb-1 fw-semibold">Sombra</label>';
$html .= ' <input type="text" id="ctaBoxShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mt-3 mb-0">';
// transition_duration
$transitionDuration = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaTransitionDuration" class="form-label small mb-1 fw-semibold">Duracion transicion</label>';
$html .= ' <input type="text" id="ctaTransitionDuration" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transitionDuration) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, bool $checked): string
{
$html = ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para CTA Lets Talk
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class CtaLetsTalkFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'cta-lets-talk';
}
public function getFieldMapping(): array
{
return [
// Visibility
'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaLetsTalkShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],
'ctaLetsTalkShowIcon' => ['group' => 'content', 'attribute' => 'show_icon'],
'ctaLetsTalkIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],
'ctaLetsTalkModalTarget' => ['group' => 'content', 'attribute' => 'modal_target'],
'ctaLetsTalkAriaLabel' => ['group' => 'content', 'attribute' => 'aria_label'],
// Behavior
'ctaLetsTalkEnableModal' => ['group' => 'behavior', 'attribute' => 'enable_modal'],
'ctaLetsTalkCustomUrl' => ['group' => 'behavior', 'attribute' => 'custom_url'],
'ctaLetsTalkOpenNewTab' => ['group' => 'behavior', 'attribute' => 'open_in_new_tab'],
// Typography
'ctaLetsTalkFontSize' => ['group' => 'typography', 'attribute' => 'font_size'],
'ctaLetsTalkFontWeight' => ['group' => 'typography', 'attribute' => 'font_weight'],
'ctaLetsTalkTextTransform' => ['group' => 'typography', 'attribute' => 'text_transform'],
// Colors
'ctaLetsTalkBgColor' => ['group' => 'colors', 'attribute' => 'background_color'],
'ctaLetsTalkBgHoverColor' => ['group' => 'colors', 'attribute' => 'background_hover_color'],
'ctaLetsTalkTextColor' => ['group' => 'colors', 'attribute' => 'text_color'],
'ctaLetsTalkTextHoverColor' => ['group' => 'colors', 'attribute' => 'text_hover_color'],
'ctaLetsTalkBorderColor' => ['group' => 'colors', 'attribute' => 'border_color'],
// Spacing
'ctaLetsTalkPaddingTB' => ['group' => 'spacing', 'attribute' => 'padding_top_bottom'],
'ctaLetsTalkPaddingLR' => ['group' => 'spacing', 'attribute' => 'padding_left_right'],
'ctaLetsTalkMarginLeft' => ['group' => 'spacing', 'attribute' => 'margin_left'],
'ctaLetsTalkIconSpacing' => ['group' => 'spacing', 'attribute' => 'icon_spacing'],
// Visual Effects
'ctaLetsTalkBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'border_radius'],
'ctaLetsTalkBorderWidth' => ['group' => 'visual_effects', 'attribute' => 'border_width'],
'ctaLetsTalkBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'box_shadow'],
'ctaLetsTalkTransition' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
];
}
}

View File

@@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* Class CtaLetsTalkFormBuilder
*
* Genera el formulario de administración para el componente CTA "Let's Talk".
*
* Responsabilidades:
* - Renderizar formulario de configuración del botón CTA
* - Organizar campos en grupos según el schema JSON
* - Aplicar Design System (gradiente navy, borde orange)
* - Usar Bootstrap 5 form controls
*
* @package ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui
*/
final class CtaLetsTalkFormBuilder
{
private const COMPONENT_ID = 'cta-lets-talk';
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
// Header
$html .= $this->buildHeader($componentId);
// Layout 2 columnas
$html .= '<div class="row g-3">';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildBehaviorGroup($componentId);
$html .= ' </div>';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildVisualEffectsGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-lightning-charge-fill me-2" style="color: #FF8600;"></i>';
$html .= ' Configuración del Botón "Let\'s Talk"';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Personaliza el botón CTA principal del navbar';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="cta-lets-talk">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
// Switch: Enabled
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkEnabled" name="visibility[is_enabled]" ';
$html .= checked($enabled, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkEnabled">';
$html .= ' <strong>Mostrar botón Let\'s Talk</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Show on Desktop
$showDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkShowDesktop" name="visibility[show_on_desktop]" ';
$html .= checked($showDesktop, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkShowDesktop">';
$html .= ' <strong>Mostrar en escritorio</strong> <span class="text-muted">(≥992px)</span>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Show on Mobile
$showMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkShowMobile" name="visibility[show_on_mobile]" ';
$html .= checked($showMobile, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkShowMobile">';
$html .= ' <strong>Mostrar en móvil</strong> <span class="text-muted">(<992px)</span>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
$html .= ' <select id="ctaLetsTalkShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-type me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// Text: Button Text
$buttonText = $this->renderer->getFieldValue($componentId, 'content', 'button_text', "Let's Talk");
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkButtonText" class="form-label small mb-1 fw-semibold">Texto del botón</label>';
$html .= ' <input type="text" id="ctaLetsTalkButtonText" name="content[button_text]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonText) . '" maxlength="30" placeholder="Let\'s Talk">';
$html .= ' </div>';
// Switch: Show Icon
$showIcon = $this->renderer->getFieldValue($componentId, 'content', 'show_icon', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkShowIcon" name="content[show_icon]" ';
$html .= checked($showIcon, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkShowIcon">';
$html .= ' <strong>Mostrar ícono</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Text: Icon Class
$iconClass = $this->renderer->getFieldValue($componentId, 'content', 'icon_class', 'bi-lightning-charge-fill');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkIconClass" class="form-label small mb-1 fw-semibold">';
$html .= ' Clase del ícono <a href="https://icons.getbootstrap.com/" target="_blank" class="text-decoration-none"><i class="bi bi-box-arrow-up-right"></i></a>';
$html .= ' </label>';
$html .= ' <input type="text" id="ctaLetsTalkIconClass" name="content[icon_class]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($iconClass) . '" placeholder="bi-lightning-charge-fill">';
$html .= ' <small class="text-muted">Usa clases de Bootstrap Icons (ej: bi-chat-dots)</small>';
$html .= ' </div>';
// Text: Modal Target
$modalTarget = $this->renderer->getFieldValue($componentId, 'content', 'modal_target', '#contactModal');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkModalTarget" class="form-label small mb-1 fw-semibold">ID del modal</label>';
$html .= ' <input type="text" id="ctaLetsTalkModalTarget" name="content[modal_target]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($modalTarget) . '" placeholder="#contactModal">';
$html .= ' </div>';
// Text: ARIA Label
$ariaLabel = $this->renderer->getFieldValue($componentId, 'content', 'aria_label', 'Abrir formulario de contacto');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkAriaLabel" class="form-label small mb-1 fw-semibold">Etiqueta ARIA (accesibilidad)</label>';
$html .= ' <input type="text" id="ctaLetsTalkAriaLabel" name="content[aria_label]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($ariaLabel) . '" maxlength="100">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildBehaviorGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-mouse me-2" style="color: #FF8600;"></i>';
$html .= ' Comportamiento';
$html .= ' </h5>';
// Switch: Enable Modal
$enableModal = $this->renderer->getFieldValue($componentId, 'behavior', 'enable_modal', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkEnableModal" name="behavior[enable_modal]" ';
$html .= checked($enableModal, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkEnableModal">';
$html .= ' <strong>Abrir modal al hacer clic</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' <small class="text-muted">Si está desactivado, usará la URL personalizada</small>';
$html .= ' </div>';
// URL: Custom URL
$customUrl = $this->renderer->getFieldValue($componentId, 'behavior', 'custom_url', '');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkCustomUrl" class="form-label small mb-1 fw-semibold">URL personalizada</label>';
$html .= ' <input type="url" id="ctaLetsTalkCustomUrl" name="behavior[custom_url]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($customUrl) . '" placeholder="https://ejemplo.com/contacto">';
$html .= ' <small class="text-muted">Solo se usa si "Abrir modal" está desactivado</small>';
$html .= ' </div>';
// Switch: Open in New Tab
$openNewTab = $this->renderer->getFieldValue($componentId, 'behavior', 'open_in_new_tab', false);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkOpenNewTab" name="behavior[open_in_new_tab]" ';
$html .= checked($openNewTab, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkOpenNewTab">';
$html .= ' <strong>Abrir en nueva pestaña</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografía';
$html .= ' </h5>';
// Text: Font Size
$fontSize = $this->renderer->getFieldValue($componentId, 'typography', 'font_size', '1rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkFontSize" class="form-label small mb-1 fw-semibold">Tamaño de fuente</label>';
$html .= ' <input type="text" id="ctaLetsTalkFontSize" name="typography[font_size]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fontSize) . '" placeholder="1rem">';
$html .= ' </div>';
// Select: Font Weight
$fontWeight = $this->renderer->getFieldValue($componentId, 'typography', 'font_weight', '600');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkFontWeight" class="form-label small mb-1 fw-semibold">Peso de fuente</label>';
$html .= ' <select id="ctaLetsTalkFontWeight" name="typography[font_weight]" class="form-select form-select-sm">';
$html .= ' <option value="400" ' . selected($fontWeight, '400', false) . '>Normal (400)</option>';
$html .= ' <option value="500" ' . selected($fontWeight, '500', false) . '>Medium (500)</option>';
$html .= ' <option value="600" ' . selected($fontWeight, '600', false) . '>Semibold (600)</option>';
$html .= ' <option value="700" ' . selected($fontWeight, '700', false) . '>Bold (700)</option>';
$html .= ' </select>';
$html .= ' </div>';
// Select: Text Transform
$textTransform = $this->renderer->getFieldValue($componentId, 'typography', 'text_transform', 'none');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkTextTransform" class="form-label small mb-1 fw-semibold">Transformación de texto</label>';
$html .= ' <select id="ctaLetsTalkTextTransform" name="typography[text_transform]" class="form-select form-select-sm">';
$html .= ' <option value="none" ' . selected($textTransform, 'none', false) . '>Normal</option>';
$html .= ' <option value="uppercase" ' . selected($textTransform, 'uppercase', false) . '>MAYÚSCULAS</option>';
$html .= ' <option value="lowercase" ' . selected($textTransform, 'lowercase', false) . '>minúsculas</option>';
$html .= ' <option value="capitalize" ' . selected($textTransform, 'capitalize', false) . '>Capitalizado</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Color: Background
$bgColor = $this->renderer->getFieldValue($componentId, 'colors', 'background_color', '#FF8600');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkBgColor" class="form-label small mb-1 fw-semibold">Color de fondo</label>';
$html .= ' <input type="color" id="ctaLetsTalkBgColor" name="colors[background_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($bgColor) . '">';
$html .= ' </div>';
// Color: Background Hover
$bgHoverColor = $this->renderer->getFieldValue($componentId, 'colors', 'background_hover_color', '#FF6B35');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkBgHoverColor" class="form-label small mb-1 fw-semibold">Color de fondo (hover)</label>';
$html .= ' <input type="color" id="ctaLetsTalkBgHoverColor" name="colors[background_hover_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($bgHoverColor) . '">';
$html .= ' </div>';
// Color: Text
$textColor = $this->renderer->getFieldValue($componentId, 'colors', 'text_color', '#FFFFFF');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkTextColor" class="form-label small mb-1 fw-semibold">Color del texto</label>';
$html .= ' <input type="color" id="ctaLetsTalkTextColor" name="colors[text_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($textColor) . '">';
$html .= ' </div>';
// Color: Text Hover
$textHoverColor = $this->renderer->getFieldValue($componentId, 'colors', 'text_hover_color', '#FFFFFF');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkTextHoverColor" class="form-label small mb-1 fw-semibold">Color del texto (hover)</label>';
$html .= ' <input type="color" id="ctaLetsTalkTextHoverColor" name="colors[text_hover_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($textHoverColor) . '">';
$html .= ' </div>';
// Text: Border Color (permite transparent)
$borderColor = $this->renderer->getFieldValue($componentId, 'colors', 'border_color', 'transparent');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkBorderColor" class="form-label small mb-1 fw-semibold">Color del borde</label>';
$html .= ' <input type="text" id="ctaLetsTalkBorderColor" name="colors[border_color]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderColor) . '" placeholder="transparent o #RRGGBB">';
$html .= ' <small class="text-muted">Usa "transparent" para sin borde visible</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-angle-expand me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
// Text: Padding Top/Bottom
$paddingTB = $this->renderer->getFieldValue($componentId, 'spacing', 'padding_top_bottom', '0.5rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkPaddingTB" class="form-label small mb-1 fw-semibold">Padding vertical</label>';
$html .= ' <input type="text" id="ctaLetsTalkPaddingTB" name="spacing[padding_top_bottom]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($paddingTB) . '" placeholder="0.5rem">';
$html .= ' </div>';
// Text: Padding Left/Right
$paddingLR = $this->renderer->getFieldValue($componentId, 'spacing', 'padding_left_right', '1.5rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkPaddingLR" class="form-label small mb-1 fw-semibold">Padding horizontal</label>';
$html .= ' <input type="text" id="ctaLetsTalkPaddingLR" name="spacing[padding_left_right]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($paddingLR) . '" placeholder="1.5rem">';
$html .= ' </div>';
// Text: Margin Left
$marginLeft = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_left', '1rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkMarginLeft" class="form-label small mb-1 fw-semibold">Margen izquierdo (desktop)</label>';
$html .= ' <input type="text" id="ctaLetsTalkMarginLeft" name="spacing[margin_left]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginLeft) . '" placeholder="1rem">';
$html .= ' <small class="text-muted">Separación del menú en pantallas ≥992px</small>';
$html .= ' </div>';
// Text: Icon Spacing
$iconSpacing = $this->renderer->getFieldValue($componentId, 'spacing', 'icon_spacing', '0.5rem');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkIconSpacing" class="form-label small mb-1 fw-semibold">Espaciado del ícono</label>';
$html .= ' <input type="text" id="ctaLetsTalkIconSpacing" name="spacing[icon_spacing]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($iconSpacing) . '" placeholder="0.5rem">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisualEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-stars me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
// Text: Border Radius
$borderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_radius', '6px');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkBorderRadius" class="form-label small mb-1 fw-semibold">Radio de bordes</label>';
$html .= ' <input type="text" id="ctaLetsTalkBorderRadius" name="visual_effects[border_radius]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '" placeholder="6px">';
$html .= ' </div>';
// Text: Border Width
$borderWidth = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_width', '0');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkBorderWidth" class="form-label small mb-1 fw-semibold">Grosor del borde</label>';
$html .= ' <input type="text" id="ctaLetsTalkBorderWidth" name="visual_effects[border_width]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderWidth) . '" placeholder="0">';
$html .= ' </div>';
// Text: Box Shadow
$boxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'box_shadow', 'none');
$html .= ' <div class="mb-2">';
$html .= ' <label for="ctaLetsTalkBoxShadow" class="form-label small mb-1 fw-semibold">Sombra</label>';
$html .= ' <input type="text" id="ctaLetsTalkBoxShadow" name="visual_effects[box_shadow]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '" placeholder="none">';
$html .= ' <small class="text-muted">Ej: 0 2px 4px rgba(0,0,0,0.1)</small>';
$html .= ' </div>';
// Text: Transition Duration
$transitionDuration = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkTransition" class="form-label small mb-1 fw-semibold">Duración de transición</label>';
$html .= ' <input type="text" id="ctaLetsTalkTransition" name="visual_effects[transition_duration]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transitionDuration) . '" placeholder="0.3s">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para CTA Post
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class CtaPostFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'cta-post';
}
public function getFieldMapping(): array
{
return [
// Visibility
'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],
'ctaPostDescription' => ['group' => 'content', 'attribute' => 'description'],
'ctaPostButtonText' => ['group' => 'content', 'attribute' => 'button_text'],
'ctaPostButtonUrl' => ['group' => 'content', 'attribute' => 'button_url'],
'ctaPostButtonIcon' => ['group' => 'content', 'attribute' => 'button_icon'],
// Typography
'ctaPostTitleFontSize' => ['group' => 'typography', 'attribute' => 'title_font_size'],
'ctaPostTitleFontWeight' => ['group' => 'typography', 'attribute' => 'title_font_weight'],
'ctaPostDescriptionFontSize' => ['group' => 'typography', 'attribute' => 'description_font_size'],
'ctaPostButtonFontSize' => ['group' => 'typography', 'attribute' => 'button_font_size'],
// Colors
'ctaPostGradientStart' => ['group' => 'colors', 'attribute' => 'gradient_start'],
'ctaPostGradientEnd' => ['group' => 'colors', 'attribute' => 'gradient_end'],
'ctaPostTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'ctaPostDescriptionColor' => ['group' => 'colors', 'attribute' => 'description_color'],
'ctaPostButtonBgColor' => ['group' => 'colors', 'attribute' => 'button_bg_color'],
'ctaPostButtonTextColor' => ['group' => 'colors', 'attribute' => 'button_text_color'],
'ctaPostButtonHoverBg' => ['group' => 'colors', 'attribute' => 'button_hover_bg'],
// Spacing
'ctaPostContainerMarginTop' => ['group' => 'spacing', 'attribute' => 'container_margin_top'],
'ctaPostContainerMarginBottom' => ['group' => 'spacing', 'attribute' => 'container_margin_bottom'],
'ctaPostContainerPadding' => ['group' => 'spacing', 'attribute' => 'container_padding'],
'ctaPostTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'ctaPostButtonIconMargin' => ['group' => 'spacing', 'attribute' => 'button_icon_margin'],
// Visual Effects
'ctaPostBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'border_radius'],
'ctaPostGradientAngle' => ['group' => 'visual_effects', 'attribute' => 'gradient_angle'],
'ctaPostButtonBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'button_border_radius'],
'ctaPostButtonPadding' => ['group' => 'visual_effects', 'attribute' => 'button_padding'],
'ctaPostTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
'ctaPostBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'box_shadow'],
];
}
}

View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para CTA Post
*
* @package ROITheme\Admin\CtaPost\Infrastructure\Ui
*/
final class CtaPostFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildTypographyGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-megaphone-fill me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de CTA Post';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' CTA promocional debajo del contenido del post';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="cta-post">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('ctaPostEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('ctaPostShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaPostShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="ctaPostShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// Title
$title = $this->renderer->getFieldValue($componentId, 'content', 'title', 'Accede a 200,000+ Analisis de Precios Unitarios');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaPostTitle" class="form-label small mb-1 fw-semibold">Titulo</label>';
$html .= ' <input type="text" id="ctaPostTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($title) . '">';
$html .= ' </div>';
// Description
$description = $this->renderer->getFieldValue($componentId, 'content', 'description', '');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaPostDescription" class="form-label small mb-1 fw-semibold">Descripcion</label>';
$html .= ' <textarea id="ctaPostDescription" class="form-control form-control-sm" rows="3">';
$html .= esc_textarea($description);
$html .= '</textarea>';
$html .= ' </div>';
// Button Text
$buttonText = $this->renderer->getFieldValue($componentId, 'content', 'button_text', 'Ver Catalogo Completo');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaPostButtonText" class="form-label small mb-1 fw-semibold">Texto del boton</label>';
$html .= ' <input type="text" id="ctaPostButtonText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonText) . '">';
$html .= ' </div>';
// Button URL
$buttonUrl = $this->renderer->getFieldValue($componentId, 'content', 'button_url', '/catalogo');
$html .= ' <div class="mb-3">';
$html .= ' <label for="ctaPostButtonUrl" class="form-label small mb-1 fw-semibold">URL del boton</label>';
$html .= ' <input type="text" id="ctaPostButtonUrl" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonUrl) . '">';
$html .= ' </div>';
// Button Icon
$buttonIcon = $this->renderer->getFieldValue($componentId, 'content', 'button_icon', 'bi-arrow-right');
$html .= ' <div class="mb-0">';
$html .= ' <label for="ctaPostButtonIcon" class="form-label small mb-1 fw-semibold">Icono del boton</label>';
$html .= ' <input type="text" id="ctaPostButtonIcon" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonIcon) . '" placeholder="bi-arrow-right">';
$html .= ' <small class="text-muted">Clase de Bootstrap Icons</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$titleFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_size', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostTitleFontSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="ctaPostTitleFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontSize) . '">';
$html .= ' </div>';
$titleFontWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_weight', '700');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostTitleFontWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="ctaPostTitleFontWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$descFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'description_font_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostDescFontSize" class="form-label small mb-1 fw-semibold">Tamano descripcion</label>';
$html .= ' <input type="text" id="ctaPostDescFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($descFontSize) . '">';
$html .= ' </div>';
$buttonFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'button_font_size', '1.125rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostButtonFontSize" class="form-label small mb-1 fw-semibold">Tamano boton</label>';
$html .= ' <input type="text" id="ctaPostButtonFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonFontSize) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Gradiente
$html .= ' <p class="small fw-semibold mb-2">Gradiente de fondo</p>';
$html .= ' <div class="row g-2 mb-3">';
$gradientStart = $this->renderer->getFieldValue($componentId, 'colors', 'gradient_start', '#FF8600');
$html .= $this->buildColorPicker('ctaPostGradientStart', 'Inicio', $gradientStart);
$gradientEnd = $this->renderer->getFieldValue($componentId, 'colors', 'gradient_end', '#FFB800');
$html .= $this->buildColorPicker('ctaPostGradientEnd', 'Fin', $gradientEnd);
$html .= ' </div>';
// Textos
$html .= ' <p class="small fw-semibold mb-2">Textos</p>';
$html .= ' <div class="row g-2 mb-3">';
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#ffffff');
$html .= $this->buildColorPicker('ctaPostTitleColor', 'Titulo', $titleColor);
$descColor = $this->renderer->getFieldValue($componentId, 'colors', 'description_color', '#ffffff');
$html .= $this->buildColorPicker('ctaPostDescColor', 'Descripcion', $descColor);
$html .= ' </div>';
// Boton
$html .= ' <p class="small fw-semibold mb-2">Boton</p>';
$html .= ' <div class="row g-2 mb-3">';
$buttonBg = $this->renderer->getFieldValue($componentId, 'colors', 'button_bg_color', '#ffffff');
$html .= $this->buildColorPicker('ctaPostButtonBg', 'Fondo', $buttonBg);
$buttonTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_text_color', '#212529');
$html .= $this->buildColorPicker('ctaPostButtonTextColor', 'Texto', $buttonTextColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$buttonHoverBg = $this->renderer->getFieldValue($componentId, 'colors', 'button_hover_bg', '#f8f9fa');
$html .= $this->buildColorPicker('ctaPostButtonHoverBg', 'Hover', $buttonHoverBg);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$marginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'container_margin_top', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="ctaPostMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginTop) . '">';
$html .= ' </div>';
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'container_margin_bottom', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="ctaPostMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$padding = $this->renderer->getFieldValue($componentId, 'spacing', 'container_padding', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostPadding" class="form-label small mb-1 fw-semibold">Padding interno</label>';
$html .= ' <input type="text" id="ctaPostPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($padding) . '">';
$html .= ' </div>';
$titleMargin = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostTitleMargin" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="ctaPostTitleMargin" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMargin) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$borderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_radius', '0.375rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostBorderRadius" class="form-label small mb-1 fw-semibold">Radio contenedor</label>';
$html .= ' <input type="text" id="ctaPostBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '">';
$html .= ' </div>';
$gradientAngle = $this->renderer->getFieldValue($componentId, 'visual_effects', 'gradient_angle', '135deg');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostGradientAngle" class="form-label small mb-1 fw-semibold">Angulo gradiente</label>';
$html .= ' <input type="text" id="ctaPostGradientAngle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($gradientAngle) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$buttonRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_border_radius', '0.375rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostButtonRadius" class="form-label small mb-1 fw-semibold">Radio boton</label>';
$html .= ' <input type="text" id="ctaPostButtonRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonRadius) . '">';
$html .= ' </div>';
$buttonPadding = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_padding', '0.5rem 1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostButtonPadding" class="form-label small mb-1 fw-semibold">Padding boton</label>';
$html .= ' <input type="text" id="ctaPostButtonPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonPadding) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$transition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostTransition" class="form-label small mb-1 fw-semibold">Transicion</label>';
$html .= ' <input type="text" id="ctaPostTransition" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transition) . '">';
$html .= ' </div>';
$boxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'box_shadow', 'none');
$html .= ' <div class="col-6">';
$html .= ' <label for="ctaPostBoxShadow" class="form-label small mb-1 fw-semibold">Sombra</label>';
$html .= ' <input type="text" id="ctaPostBoxShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Domain\Contracts;
/**
* Contrato para tabs de componentes en el dashboard
*
* Domain - Lógica pura sin WordPress
*/
interface ComponentTabInterface
{
/**
* Obtiene el ID del componente
*
* @return string
*/
public function getId(): string;
/**
* Obtiene el nombre visible del tab
*
* @return string
*/
public function getLabel(): string;
/**
* Obtiene el ícono del tab
*
* @return string
*/
public function getIcon(): string;
/**
* Renderiza el contenido del tab
*
* @return string HTML del contenido
*/
public function renderContent(): string;
/**
* Verifica si el tab está activo
*
* @return bool
*/
public function isActive(): bool;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Domain\Contracts;
/**
* Contrato para renderizar el dashboard del panel de administración
*
* Domain - Lógica pura sin WordPress
*/
interface DashboardRendererInterface
{
/**
* Renderiza el dashboard completo
*
* @return string HTML del dashboard
*/
public function render(): string;
/**
* Verifica si el renderizador soporta un tipo de vista
*
* @param string $viewType Tipo de vista (dashboard, settings, etc.)
* @return bool
*/
public function supports(string $viewType): bool;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Domain\Contracts;
/**
* Contrato para registrar el menú de administración
*
* Domain - Lógica pura sin WordPress
*/
interface MenuRegistrarInterface
{
/**
* Registra el menú en el sistema de administración
*
* @return void
*/
public function register(): void;
/**
* Obtiene la capacidad requerida para acceder al menú
*
* @return string
*/
public function getCapability(): string;
/**
* Obtiene el slug del menú
*
* @return string
*/
public function getSlug(): string;
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Domain\ValueObjects;
/**
* Value Object para representar un item del menú
*
* Domain - Objeto inmutable sin WordPress
*/
final class MenuItem
{
/**
* @param string $pageTitle Título de la página
* @param string $menuTitle Título del menú
* @param string $capability Capacidad requerida
* @param string $menuSlug Slug del menú
* @param string $icon Ícono del menú
* @param int $position Posición en el menú
*/
public function __construct(
private readonly string $pageTitle,
private readonly string $menuTitle,
private readonly string $capability,
private readonly string $menuSlug,
private readonly string $icon,
private readonly int $position
) {
$this->validate();
}
private function validate(): void
{
if (empty($this->pageTitle)) {
throw new \InvalidArgumentException('Page title cannot be empty');
}
if (empty($this->menuTitle)) {
throw new \InvalidArgumentException('Menu title cannot be empty');
}
if (empty($this->capability)) {
throw new \InvalidArgumentException('Capability cannot be empty');
}
if (empty($this->menuSlug)) {
throw new \InvalidArgumentException('Menu slug cannot be empty');
}
if ($this->position < 0) {
throw new \InvalidArgumentException('Position must be >= 0');
}
}
public function getPageTitle(): string
{
return $this->pageTitle;
}
public function getMenuTitle(): string
{
return $this->menuTitle;
}
public function getCapability(): string
{
return $this->capability;
}
public function getMenuSlug(): string
{
return $this->menuSlug;
}
public function getIcon(): string
{
return $this->icon;
}
public function getPosition(): int
{
return $this->position;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Featured Image
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class FeaturedImageFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'featured-image';
}
public function getFieldMapping(): array
{
return [
// Visibility
'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'featuredImageShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],
'featuredImageLazyLoading' => ['group' => 'content', 'attribute' => 'lazy_loading'],
'featuredImageLinkToMedia' => ['group' => 'content', 'attribute' => 'link_to_media'],
// Spacing
'featuredImageMarginTop' => ['group' => 'spacing', 'attribute' => 'margin_top'],
'featuredImageMarginBottom' => ['group' => 'spacing', 'attribute' => 'margin_bottom'],
// Visual Effects
'featuredImageBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'border_radius'],
'featuredImageBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'box_shadow'],
'featuredImageHoverEffect' => ['group' => 'visual_effects', 'attribute' => 'hover_effect'],
'featuredImageHoverScale' => ['group' => 'visual_effects', 'attribute' => 'hover_scale'],
'featuredImageTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
];
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
final class FeaturedImageFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= ' </div>';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-image me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Imagen Destacada';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Personaliza la imagen destacada de los posts';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="featured-image">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="featuredImageEnabled" ';
$html .= checked($enabled, true, false) . '>';
$html .= ' <label class="form-check-label small" for="featuredImageEnabled">';
$html .= ' <i class="bi bi-power me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar imagen destacada</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="featuredImageShowOnDesktop" ';
$html .= checked($showOnDesktop, true, false) . '>';
$html .= ' <label class="form-check-label small" for="featuredImageShowOnDesktop">';
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar en Desktop</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="featuredImageShowOnMobile" ';
$html .= checked($showOnMobile, true, false) . '>';
$html .= ' <label class="form-check-label small" for="featuredImageShowOnMobile">';
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar en Mobile</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="featuredImageShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="featuredImageShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-image me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
$imageSize = $this->renderer->getFieldValue($componentId, 'content', 'image_size', 'roi-featured-large');
$html .= ' <div class="mb-3">';
$html .= ' <label for="featuredImageSize" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-aspect-ratio me-1" style="color: #FF8600;"></i>';
$html .= ' Tamano de imagen';
$html .= ' </label>';
$html .= ' <select id="featuredImageSize" class="form-select form-select-sm">';
$html .= ' <option value="roi-featured-large" ' . selected($imageSize, 'roi-featured-large', false) . '>Grande (1200x600)</option>';
$html .= ' <option value="roi-featured-medium" ' . selected($imageSize, 'roi-featured-medium', false) . '>Mediano (800x400)</option>';
$html .= ' <option value="full" ' . selected($imageSize, 'full', false) . '>Original (tamano completo)</option>';
$html .= ' </select>';
$html .= ' </div>';
$lazyLoading = $this->renderer->getFieldValue($componentId, 'content', 'lazy_loading', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="featuredImageLazyLoading" ';
$html .= checked($lazyLoading, true, false) . '>';
$html .= ' <label class="form-check-label small" for="featuredImageLazyLoading">';
$html .= ' <i class="bi bi-lightning me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Carga diferida (lazy loading)</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' <small class="text-muted">Mejora rendimiento cargando imagen cuando es visible</small>';
$html .= ' </div>';
$linkToMedia = $this->renderer->getFieldValue($componentId, 'content', 'link_to_media', false);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="featuredImageLinkToMedia" ';
$html .= checked($linkToMedia, true, false) . '>';
$html .= ' <label class="form-check-label small" for="featuredImageLinkToMedia">';
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Enlazar a imagen completa</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' <small class="text-muted">Abre la imagen en tamano completo al hacer clic</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-0">';
$marginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_top', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="featuredImageMarginTop" class="form-label small mb-1 fw-semibold">';
$html .= ' Margen superior';
$html .= ' </label>';
$html .= ' <input type="text" id="featuredImageMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginTop) . '" placeholder="1rem">';
$html .= ' </div>';
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_bottom', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="featuredImageMarginBottom" class="form-label small mb-1 fw-semibold">';
$html .= ' Margen inferior';
$html .= ' </label>';
$html .= ' <input type="text" id="featuredImageMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginBottom) . '" placeholder="2rem">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$borderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_radius', '12px');
$html .= ' <div class="mb-2">';
$html .= ' <label for="featuredImageBorderRadius" class="form-label small mb-1 fw-semibold">';
$html .= ' Radio de bordes';
$html .= ' </label>';
$html .= ' <input type="text" id="featuredImageBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '" placeholder="12px">';
$html .= ' </div>';
$boxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'box_shadow', '0 8px 24px rgba(0, 0, 0, 0.1)');
$html .= ' <div class="mb-3">';
$html .= ' <label for="featuredImageBoxShadow" class="form-label small mb-1 fw-semibold">';
$html .= ' Sombra';
$html .= ' </label>';
$html .= ' <input type="text" id="featuredImageBoxShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '">';
$html .= ' </div>';
$hoverEffect = $this->renderer->getFieldValue($componentId, 'visual_effects', 'hover_effect', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="featuredImageHoverEffect" ';
$html .= checked($hoverEffect, true, false) . '>';
$html .= ' <label class="form-check-label small" for="featuredImageHoverEffect">';
$html .= ' <i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Efecto hover</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' <small class="text-muted">Aplica efecto de escala sutil al pasar el mouse</small>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$hoverScale = $this->renderer->getFieldValue($componentId, 'visual_effects', 'hover_scale', '1.02');
$html .= ' <div class="col-6">';
$html .= ' <label for="featuredImageHoverScale" class="form-label small mb-1 fw-semibold">';
$html .= ' Escala en hover';
$html .= ' </label>';
$html .= ' <input type="text" id="featuredImageHoverScale" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($hoverScale) . '" placeholder="1.02">';
$html .= ' </div>';
$transitionDuration = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="col-6">';
$html .= ' <label for="featuredImageTransitionDuration" class="form-label small mb-1 fw-semibold">';
$html .= ' Duracion transicion';
$html .= ' </label>';
$html .= ' <input type="text" id="featuredImageTransitionDuration" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transitionDuration) . '" placeholder="0.3s">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Footer
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class FooterFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'footer';
}
public function getFieldMapping(): array
{
return [
// Visibility
'footerEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'footerShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Widget 1
'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'],
'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'],
// Widget 2
'footerWidget2Visible' => ['group' => 'widget_2', 'attribute' => 'widget_2_visible'],
'footerWidget2Title' => ['group' => 'widget_2', 'attribute' => 'widget_2_title'],
// Widget 3
'footerWidget3Visible' => ['group' => 'widget_3', 'attribute' => 'widget_3_visible'],
'footerWidget3Title' => ['group' => 'widget_3', 'attribute' => 'widget_3_title'],
// Newsletter
'footerNewsletterVisible' => ['group' => 'newsletter', 'attribute' => 'newsletter_visible'],
'footerNewsletterTitle' => ['group' => 'newsletter', 'attribute' => 'newsletter_title'],
'footerNewsletterDescription' => ['group' => 'newsletter', 'attribute' => 'newsletter_description'],
'footerNewsletterPlaceholder' => ['group' => 'newsletter', 'attribute' => 'newsletter_email_placeholder'],
'footerNewsletterButtonText' => ['group' => 'newsletter', 'attribute' => 'newsletter_button_text'],
'footerNewsletterWebhookUrl' => ['group' => 'newsletter', 'attribute' => 'newsletter_webhook_url'],
'footerNewsletterSuccessMessage' => ['group' => 'newsletter', 'attribute' => 'newsletter_success_message'],
'footerNewsletterErrorMessage' => ['group' => 'newsletter', 'attribute' => 'newsletter_error_message'],
// Footer Bottom
'footerCopyrightText' => ['group' => 'footer_bottom', 'attribute' => 'copyright_text'],
// Colors
'footerBgColor' => ['group' => 'colors', 'attribute' => 'bg_color'],
'footerTextColor' => ['group' => 'colors', 'attribute' => 'text_color'],
'footerTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'footerLinkColor' => ['group' => 'colors', 'attribute' => 'link_color'],
'footerLinkHoverColor' => ['group' => 'colors', 'attribute' => 'link_hover_color'],
'footerButtonBgColor' => ['group' => 'colors', 'attribute' => 'button_bg_color'],
'footerButtonTextColor' => ['group' => 'colors', 'attribute' => 'button_text_color'],
'footerButtonHoverBg' => ['group' => 'colors', 'attribute' => 'button_hover_bg'],
// Spacing
'footerPaddingY' => ['group' => 'spacing', 'attribute' => 'padding_y'],
'footerMarginTop' => ['group' => 'spacing', 'attribute' => 'margin_top'],
// Visual Effects
'footerInputBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'input_border_radius'],
'footerButtonBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'button_border_radius'],
'footerTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
];
}
}

View File

@@ -0,0 +1,413 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para Footer
*
* RESPONSABILIDAD: Generar formulario de configuracion del Footer
*
* @package ROITheme\Admin\Footer\Infrastructure\Ui
*/
final class FooterFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildWidget1Group($componentId);
$html .= $this->buildWidget2Group($componentId);
$html .= $this->buildWidget3Group($componentId);
$html .= $this->buildNewsletterGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildFooterBottomGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-layout-text-window-reverse me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Footer';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Footer con menus de navegacion y newsletter';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="footer">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('footerEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('footerShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildWidget1Group(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>';
$html .= ' Widget 1 (Menu)';
$html .= ' </h5>';
$visible = $this->renderer->getFieldValue($componentId, 'widget_1', 'widget_1_visible', true);
$html .= $this->buildSwitch('footerWidget1Visible', 'Mostrar Widget 1', 'bi-eye', $visible);
$title = $this->renderer->getFieldValue($componentId, 'widget_1', 'widget_1_title', 'Recursos');
$html .= $this->buildTextInput('footerWidget1Title', 'Titulo', 'bi-type', $title);
$html .= ' <div class="alert alert-info small mb-0 mt-2">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' El contenido se gestiona desde <strong>Apariencia &gt; Menus &gt; Footer Menu 1</strong>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildWidget2Group(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>';
$html .= ' Widget 2 (Menu)';
$html .= ' </h5>';
$visible = $this->renderer->getFieldValue($componentId, 'widget_2', 'widget_2_visible', true);
$html .= $this->buildSwitch('footerWidget2Visible', 'Mostrar Widget 2', 'bi-eye', $visible);
$title = $this->renderer->getFieldValue($componentId, 'widget_2', 'widget_2_title', 'Soporte');
$html .= $this->buildTextInput('footerWidget2Title', 'Titulo', 'bi-type', $title);
$html .= ' <div class="alert alert-info small mb-0 mt-2">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' El contenido se gestiona desde <strong>Apariencia &gt; Menus &gt; Footer Menu 2</strong>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildWidget3Group(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>';
$html .= ' Widget 3 (Menu)';
$html .= ' </h5>';
$visible = $this->renderer->getFieldValue($componentId, 'widget_3', 'widget_3_visible', true);
$html .= $this->buildSwitch('footerWidget3Visible', 'Mostrar Widget 3', 'bi-eye', $visible);
$title = $this->renderer->getFieldValue($componentId, 'widget_3', 'widget_3_title', 'Empresa');
$html .= $this->buildTextInput('footerWidget3Title', 'Titulo', 'bi-type', $title);
$html .= ' <div class="alert alert-info small mb-0 mt-2">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' El contenido se gestiona desde <strong>Apariencia &gt; Menus &gt; Footer Menu 3</strong>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildNewsletterGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-envelope-paper me-2" style="color: #FF8600;"></i>';
$html .= ' Newsletter';
$html .= ' </h5>';
$visible = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_visible', true);
$html .= $this->buildSwitch('footerNewsletterVisible', 'Mostrar Newsletter', 'bi-eye', $visible);
$title = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_title', 'Suscribete al Newsletter');
$html .= $this->buildTextInput('footerNewsletterTitle', 'Titulo', 'bi-type', $title);
$description = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_description', 'Recibe las ultimas actualizaciones.');
$html .= $this->buildTextarea('footerNewsletterDescription', 'Descripcion', 'bi-text-paragraph', $description);
$placeholder = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_email_placeholder', 'Email');
$html .= $this->buildTextInput('footerNewsletterPlaceholder', 'Placeholder email', 'bi-input-cursor', $placeholder);
$buttonText = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_button_text', 'Suscribirse');
$html .= $this->buildTextInput('footerNewsletterButtonText', 'Texto boton', 'bi-cursor', $buttonText);
$webhookUrl = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_webhook_url', '');
$html .= $this->buildTextarea('footerNewsletterWebhookUrl', 'URL del Webhook', 'bi-link-45deg', $webhookUrl);
$successMsg = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_success_message', 'Gracias por suscribirte!');
$html .= $this->buildTextInput('footerNewsletterSuccessMessage', 'Mensaje exito', 'bi-check-circle', $successMsg);
$errorMsg = $this->renderer->getFieldValue($componentId, 'newsletter', 'newsletter_error_message', 'Error al suscribirse.');
$html .= $this->buildTextInput('footerNewsletterErrorMessage', 'Mensaje error', 'bi-x-circle', $errorMsg);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildFooterBottomGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-c-circle me-2" style="color: #FF8600;"></i>';
$html .= ' Pie de Footer';
$html .= ' </h5>';
$copyright = $this->renderer->getFieldValue($componentId, 'footer_bottom', 'copyright_text', date('Y') . ' Todos los derechos reservados.');
$html .= $this->buildTextInput('footerCopyrightText', 'Texto copyright', 'bi-c-circle', $copyright);
$html .= ' <div class="alert alert-secondary small mb-0 mt-2">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' El simbolo &copy; se agrega automaticamente';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
$bgColor = $this->renderer->getFieldValue($componentId, 'colors', 'bg_color', '#212529');
$html .= $this->buildColorInput('footerBgColor', 'Fondo footer', $bgColor);
$textColor = $this->renderer->getFieldValue($componentId, 'colors', 'text_color', '#ffffff');
$html .= $this->buildColorInput('footerTextColor', 'Color texto', $textColor);
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#ffffff');
$html .= $this->buildColorInput('footerTitleColor', 'Color titulos', $titleColor);
$linkColor = $this->renderer->getFieldValue($componentId, 'colors', 'link_color', '#ffffff');
$html .= $this->buildColorInput('footerLinkColor', 'Color links', $linkColor);
$linkHoverColor = $this->renderer->getFieldValue($componentId, 'colors', 'link_hover_color', '#FF8600');
$html .= $this->buildColorInput('footerLinkHoverColor', 'Color links hover', $linkHoverColor);
$buttonBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_bg_color', '#0d6efd');
$html .= $this->buildColorInput('footerButtonBgColor', 'Fondo boton', $buttonBgColor);
$buttonTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'button_text_color', '#ffffff');
$html .= $this->buildColorInput('footerButtonTextColor', 'Texto boton', $buttonTextColor);
$buttonHoverBg = $this->renderer->getFieldValue($componentId, 'colors', 'button_hover_bg', '#0b5ed7');
$html .= $this->buildColorInput('footerButtonHoverBg', 'Fondo boton hover', $buttonHoverBg);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-expand me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$paddingY = $this->renderer->getFieldValue($componentId, 'spacing', 'padding_y', '3rem');
$html .= $this->buildTextInput('footerPaddingY', 'Padding vertical', 'bi-arrows-vertical', $paddingY);
$marginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_top', '0');
$html .= $this->buildTextInput('footerMarginTop', 'Margen superior', 'bi-arrow-up', $marginTop);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-stars me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$inputRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'input_border_radius', '6px');
$html .= $this->buildTextInput('footerInputBorderRadius', 'Radio input', 'bi-square', $inputRadius);
$buttonRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_border_radius', '6px');
$html .= $this->buildTextInput('footerButtonBorderRadius', 'Radio boton', 'bi-square', $buttonRadius);
$transition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= $this->buildTextInput('footerTransitionDuration', 'Duracion transicion', 'bi-hourglass', $transition);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
// Helper methods
private function buildSwitch(string $id, string $label, string $icon, $value): string
{
$checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
$html = ' <div class="form-check form-switch mb-2">';
$html .= ' <input class="form-check-input" type="checkbox" id="' . esc_attr($id) . '" ' . $checked . '>';
$html .= ' <label class="form-check-label small" for="' . esc_attr($id) . '">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
private function buildTextInput(string $id, string $label, string $icon, mixed $value): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <input type="text" class="form-control form-control-sm" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '">';
$html .= ' </div>';
return $html;
}
private function buildPasswordInput(string $id, string $label, string $icon, mixed $value): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <input type="password" class="form-control form-control-sm" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '">';
$html .= ' <div class="form-text small">URL oculta por seguridad</div>';
$html .= ' </div>';
return $html;
}
private function buildTextarea(string $id, string $label, string $icon, mixed $value): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <textarea class="form-control form-control-sm" id="' . esc_attr($id) . '" rows="2">' . esc_textarea($value) . '</textarea>';
$html .= ' </div>';
return $html;
}
private function buildColorInput(string $id, string $label, mixed $value): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-2 d-flex align-items-center gap-2">';
$html .= ' <input type="color" class="form-control form-control-color" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '" style="width: 40px; height: 30px;">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label mb-0 small">' . esc_html($label) . '</label>';
$html .= ' </div>';
return $html;
}
/**
* Normaliza un valor a string para inputs de formulario
*
* El repositorio convierte '0' a false y '1' a true automáticamente,
* pero para campos de texto necesitamos el valor original como string.
*/
private function normalizeStringValue(mixed $value): string
{
if ($value === false) {
return '0';
}
if ($value === true) {
return '1';
}
return (string) $value;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Hero Section
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class HeroFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'hero';
}
public function getFieldMapping(): array
{
return [
// Visibility
'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'],
'heroBadgeIconClass' => ['group' => 'content', 'attribute' => 'badge_icon_class'],
'heroTitleTag' => ['group' => 'content', 'attribute' => 'title_tag'],
// Colors
'heroGradientStart' => ['group' => 'colors', 'attribute' => 'gradient_start'],
'heroGradientEnd' => ['group' => 'colors', 'attribute' => 'gradient_end'],
'heroTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'heroBadgeBgColor' => ['group' => 'colors', 'attribute' => 'badge_bg_color'],
'heroBadgeTextColor' => ['group' => 'colors', 'attribute' => 'badge_text_color'],
'heroBadgeIconColor' => ['group' => 'colors', 'attribute' => 'badge_icon_color'],
'heroBadgeHoverBg' => ['group' => 'colors', 'attribute' => 'badge_hover_bg'],
// Typography
'heroTitleFontSize' => ['group' => 'typography', 'attribute' => 'title_font_size'],
'heroTitleFontSizeMobile' => ['group' => 'typography', 'attribute' => 'title_font_size_mobile'],
'heroTitleFontWeight' => ['group' => 'typography', 'attribute' => 'title_font_weight'],
'heroTitleLineHeight' => ['group' => 'typography', 'attribute' => 'title_line_height'],
'heroBadgeFontSize' => ['group' => 'typography', 'attribute' => 'badge_font_size'],
// Spacing
'heroPaddingVertical' => ['group' => 'spacing', 'attribute' => 'padding_vertical'],
'heroMarginBottom' => ['group' => 'spacing', 'attribute' => 'margin_bottom'],
'heroBadgePadding' => ['group' => 'spacing', 'attribute' => 'badge_padding'],
'heroBadgeBorderRadius' => ['group' => 'spacing', 'attribute' => 'badge_border_radius'],
// Visual Effects
'heroBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'box_shadow'],
'heroTitleTextShadow' => ['group' => 'visual_effects', 'attribute' => 'title_text_shadow'],
'heroBadgeBackdropBlur' => ['group' => 'visual_effects', 'attribute' => 'badge_backdrop_blur'],
];
}
}

View File

@@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
final class HeroFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= ' </div>';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-image me-2" style="color: #FF8600;"></i>';
$html .= ' Configuración de Hero Section';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Personaliza la sección hero con título y badges de categorías';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="hero">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroEnabled" ';
$html .= checked($enabled, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroEnabled">';
$html .= ' <i class="bi bi-power me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Activar Hero Section</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroShowOnDesktop" ';
$html .= checked($showOnDesktop, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroShowOnDesktop">';
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar en Desktop</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroShowOnMobile" ';
$html .= checked($showOnMobile, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroShowOnMobile">';
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar en Mobile</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="heroShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
$showCategories = $this->renderer->getFieldValue($componentId, 'content', 'show_categories', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroShowCategories" ';
$html .= checked($showCategories, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroShowCategories">';
$html .= ' <i class="bi bi-tags me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar badges de categorías</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$showBadgeIcon = $this->renderer->getFieldValue($componentId, 'content', 'show_badge_icon', true);
$html .= ' <div class="mb-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroShowBadgeIcon" ';
$html .= checked($showBadgeIcon, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroShowBadgeIcon">';
$html .= ' <i class="bi bi-star me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar ícono en badges</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$badgeIconClass = $this->renderer->getFieldValue($componentId, 'content', 'badge_icon_class', 'bi-folder-fill');
$html .= ' <div class="mb-3">';
$html .= ' <label for="heroBadgeIconClass" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-bootstrap me-1" style="color: #FF8600;"></i>';
$html .= ' Clase del ícono de badge';
$html .= ' </label>';
$html .= ' <input type="text" id="heroBadgeIconClass" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($badgeIconClass) . '" placeholder="bi-folder-fill">';
$html .= ' <small class="text-muted">Usa clases de Bootstrap Icons</small>';
$html .= ' </div>';
$titleTag = $this->renderer->getFieldValue($componentId, 'content', 'title_tag', 'h1');
$html .= ' <div class="mb-0">';
$html .= ' <label for="heroTitleTag" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-code me-1" style="color: #FF8600;"></i>';
$html .= ' Etiqueta HTML del título';
$html .= ' </label>';
$html .= ' <select id="heroTitleTag" class="form-select form-select-sm">';
$html .= ' <option value="h1" ' . selected($titleTag, 'h1', false) . '>H1 (recomendado para SEO)</option>';
$html .= ' <option value="h2" ' . selected($titleTag, 'h2', false) . '>H2</option>';
$html .= ' <option value="div" ' . selected($titleTag, 'div', false) . '>DIV (sin semántica)</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-2">';
$gradientStart = $this->renderer->getFieldValue($componentId, 'colors', 'gradient_start', '#1e3a5f');
$html .= $this->buildColorPicker('heroGradientStart', 'Degradado (inicio)', 'circle-half', $gradientStart);
$gradientEnd = $this->renderer->getFieldValue($componentId, 'colors', 'gradient_end', '#2c5282');
$html .= $this->buildColorPicker('heroGradientEnd', 'Degradado (fin)', 'circle-fill', $gradientEnd);
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#FFFFFF');
$html .= $this->buildColorPicker('heroTitleColor', 'Color título', 'fonts', $titleColor);
$badgeBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'badge_bg_color', '#FFFFFF');
$html .= $this->buildColorPicker('heroBadgeBgColor', 'Fondo badges', 'badge', $badgeBgColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$badgeTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'badge_text_color', '#FFFFFF');
$html .= $this->buildColorPicker('heroBadgeTextColor', 'Texto badges', 'card-text', $badgeTextColor);
$badgeIconColor = $this->renderer->getFieldValue($componentId, 'colors', 'badge_icon_color', '#FFB800');
$html .= $this->buildColorPicker('heroBadgeIconColor', 'Ícono badges', 'star-fill', $badgeIconColor);
$badgeHoverBg = $this->renderer->getFieldValue($componentId, 'colors', 'badge_hover_bg', '#FF8600');
$html .= $this->buildColorPicker('heroBadgeHoverBg', 'Badges (hover)', 'hand-index', $badgeHoverBg);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-type me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografía';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-2">';
$titleFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_size', '2.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroTitleFontSize" class="form-label small mb-1 fw-semibold">';
$html .= ' Tamaño desktop';
$html .= ' </label>';
$html .= ' <input type="text" id="heroTitleFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontSize) . '">';
$html .= ' </div>';
$titleFontSizeMobile = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_size_mobile', '1.75rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroTitleFontSizeMobile" class="form-label small mb-1 fw-semibold">';
$html .= ' Tamaño mobile';
$html .= ' </label>';
$html .= ' <input type="text" id="heroTitleFontSizeMobile" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontSizeMobile) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-2">';
$titleFontWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_weight', '700');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroTitleFontWeight" class="form-label small mb-1 fw-semibold">';
$html .= ' Peso del título';
$html .= ' </label>';
$html .= ' <select id="heroTitleFontWeight" class="form-select form-select-sm">';
$html .= ' <option value="400" ' . selected($titleFontWeight, '400', false) . '>Normal (400)</option>';
$html .= ' <option value="500" ' . selected($titleFontWeight, '500', false) . '>Medium (500)</option>';
$html .= ' <option value="600" ' . selected($titleFontWeight, '600', false) . '>Semibold (600)</option>';
$html .= ' <option value="700" ' . selected($titleFontWeight, '700', false) . '>Bold (700)</option>';
$html .= ' </select>';
$html .= ' </div>';
$titleLineHeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_line_height', '1.4');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroTitleLineHeight" class="form-label small mb-1 fw-semibold">';
$html .= ' Altura de línea';
$html .= ' </label>';
$html .= ' <input type="text" id="heroTitleLineHeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleLineHeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$badgeFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'badge_font_size', '0.813rem');
$html .= ' <div class="mb-0">';
$html .= ' <label for="heroBadgeFontSize" class="form-label small mb-1 fw-semibold">';
$html .= ' Tamaño fuente badges';
$html .= ' </label>';
$html .= ' <input type="text" id="heroBadgeFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($badgeFontSize) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-2">';
$paddingVertical = $this->renderer->getFieldValue($componentId, 'spacing', 'padding_vertical', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroPaddingVertical" class="form-label small mb-1 fw-semibold">';
$html .= ' Padding vertical';
$html .= ' </label>';
$html .= ' <input type="text" id="heroPaddingVertical" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($paddingVertical) . '">';
$html .= ' </div>';
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_bottom', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroMarginBottom" class="form-label small mb-1 fw-semibold">';
$html .= ' Margen inferior';
$html .= ' </label>';
$html .= ' <input type="text" id="heroMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$badgePadding = $this->renderer->getFieldValue($componentId, 'spacing', 'badge_padding', '0.375rem 0.875rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroBadgePadding" class="form-label small mb-1 fw-semibold">';
$html .= ' Padding badges';
$html .= ' </label>';
$html .= ' <input type="text" id="heroBadgePadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($badgePadding) . '">';
$html .= ' </div>';
$badgeBorderRadius = $this->renderer->getFieldValue($componentId, 'spacing', 'badge_border_radius', '20px');
$html .= ' <div class="col-6">';
$html .= ' <label for="heroBadgeBorderRadius" class="form-label small mb-1 fw-semibold">';
$html .= ' Border radius badges';
$html .= ' </label>';
$html .= ' <input type="text" id="heroBadgeBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($badgeBorderRadius) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos';
$html .= ' </h5>';
$boxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'box_shadow', '0 4px 16px rgba(30, 58, 95, 0.25)');
$html .= ' <div class="mb-2">';
$html .= ' <label for="heroBoxShadow" class="form-label small mb-1 fw-semibold">';
$html .= ' Sombra del hero';
$html .= ' </label>';
$html .= ' <input type="text" id="heroBoxShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '">';
$html .= ' </div>';
$titleTextShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'title_text_shadow', '1px 1px 2px rgba(0, 0, 0, 0.2)');
$html .= ' <div class="mb-2">';
$html .= ' <label for="heroTitleTextShadow" class="form-label small mb-1 fw-semibold">';
$html .= ' Sombra del título';
$html .= ' </label>';
$html .= ' <input type="text" id="heroTitleTextShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleTextShadow) . '">';
$html .= ' </div>';
$badgeBackdropBlur = $this->renderer->getFieldValue($componentId, 'visual_effects', 'badge_backdrop_blur', '10px');
$html .= ' <div class="mb-0">';
$html .= ' <label for="heroBadgeBackdropBlur" class="form-label small mb-1 fw-semibold">';
$html .= ' Blur de fondo badges';
$html .= ' </label>';
$html .= ' <input type="text" id="heroBadgeBackdropBlur" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($badgeBackdropBlur) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $icon, string $value): string
{
$html = ' <div class="col-6">';
$html .= ' <label for="' . $id . '" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
$html .= ' <i class="bi bi-' . $icon . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . $label;
$html .= ' </label>';
$html .= ' <input type="color" id="' . $id . '" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($value) . '" title="' . esc_attr($label) . '">';
$html .= ' <small class="text-muted d-block mt-1" id="' . $id . 'Value">' . esc_html(strtoupper($value)) . '</small>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Infrastructure\Api\Wordpress;
use ROITheme\Admin\Domain\Contracts\MenuRegistrarInterface;
use ROITheme\Admin\Domain\ValueObjects\MenuItem;
use ROITheme\Admin\Application\UseCases\RenderDashboardUseCase;
/**
* Registra el menú de administración en WordPress
*
* Infrastructure - Implementación específica de WordPress
*/
final class AdminMenuRegistrar implements MenuRegistrarInterface
{
private MenuItem $menuItem;
/**
* @param MenuItem $menuItem Configuración del menú
* @param RenderDashboardUseCase $renderUseCase Caso de uso para renderizar
*/
public function __construct(
MenuItem $menuItem,
private readonly RenderDashboardUseCase $renderUseCase
) {
$this->menuItem = $menuItem;
}
/**
* Registra el menú en WordPress
*/
public function register(): void
{
add_action('admin_menu', [$this, 'addMenuPage']);
}
/**
* Callback para agregar la página al menú de WordPress
*/
public function addMenuPage(): void
{
add_menu_page(
$this->menuItem->getPageTitle(),
$this->menuItem->getMenuTitle(),
$this->menuItem->getCapability(),
$this->menuItem->getMenuSlug(),
[$this, 'renderPage'],
$this->menuItem->getIcon(),
$this->menuItem->getPosition()
);
}
/**
* Callback para renderizar la página
*/
public function renderPage(): void
{
try {
echo $this->renderUseCase->execute('dashboard');
} catch (\Exception $e) {
echo '<div class="error"><p>Error rendering dashboard: ' . esc_html($e->getMessage()) . '</p></div>';
}
}
public function getCapability(): string
{
return $this->menuItem->getCapability();
}
public function getSlug(): string
{
return $this->menuItem->getMenuSlug();
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Infrastructure\Services;
/**
* Servicio para enqueue de assets del panel de administración
*
* Infrastructure - WordPress specific
*/
final class AdminAssetEnqueuer
{
private const ADMIN_PAGE_SLUG = 'roi-theme-admin';
public function __construct(
private readonly string $themeUri
) {
}
/**
* Registra los hooks de WordPress
*/
public function register(): void
{
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
}
/**
* Enqueue de assets solo en la página del dashboard
*
* @param string $hook Hook name de WordPress
*/
public function enqueueAssets(string $hook): void
{
// Solo cargar en nuestra página de admin
if (!$this->isAdminPage($hook)) {
return;
}
$this->enqueueStyles();
$this->enqueueScripts();
}
/**
* Verifica si estamos en la página del dashboard
*
* @param string $hook Hook name
* @return bool
*/
private function isAdminPage(string $hook): bool
{
return strpos($hook, self::ADMIN_PAGE_SLUG) !== false;
}
/**
* Enqueue de estilos CSS
*/
private function enqueueStyles(): void
{
// Bootstrap 5 CSS
wp_enqueue_style(
'bootstrap',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css',
[],
'5.3.2'
);
// Bootstrap Icons
wp_enqueue_style(
'bootstrap-icons',
'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
[],
'1.11.3'
);
// Estilos del dashboard
wp_enqueue_style(
'roi-admin-dashboard',
$this->themeUri . '/Admin/Infrastructure/Ui/Assets/Css/admin-dashboard.css',
['bootstrap', 'bootstrap-icons'],
filemtime(get_template_directory() . '/Admin/Infrastructure/Ui/Assets/Css/admin-dashboard.css')
);
}
/**
* Enqueue de scripts JavaScript
*/
private function enqueueScripts(): void
{
// Bootstrap 5 JS Bundle (incluye Popper)
// IMPORTANTE: Cargar en header (false) para que esté disponible antes del contenido
wp_enqueue_script(
'bootstrap',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',
[],
'5.3.2',
false // Load in header, not footer - required for Bootstrap tabs to work
);
// Script del dashboard
wp_enqueue_script(
'roi-admin-dashboard',
$this->themeUri . '/Admin/Infrastructure/Ui/Assets/Js/admin-dashboard.js',
['bootstrap'],
filemtime(get_template_directory() . '/Admin/Infrastructure/Ui/Assets/Js/admin-dashboard.js'),
true
);
// Pasar variables al JavaScript
wp_localize_script(
'roi-admin-dashboard',
'roiAdminDashboard',
[
'nonce' => wp_create_nonce('roi_admin_dashboard'),
'ajaxurl' => admin_url('admin-ajax.php')
]
);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Infrastructure\Ui;
use ROITheme\Admin\Domain\Contracts\DashboardRendererInterface;
use ROITheme\Shared\Application\UseCases\GetComponentSettings\GetComponentSettingsUseCase;
/**
* Renderiza el dashboard del panel de administración
*
* Infrastructure - Implementación con WordPress
*/
final class AdminDashboardRenderer implements DashboardRendererInterface
{
private const SUPPORTED_VIEWS = ['dashboard'];
/**
* @param GetComponentSettingsUseCase|null $getComponentSettingsUseCase
* @param array<string, mixed> $components Componentes disponibles
*/
public function __construct(
private readonly ?GetComponentSettingsUseCase $getComponentSettingsUseCase = null,
private readonly array $components = []
) {
}
public function render(): string
{
ob_start();
require __DIR__ . '/Views/dashboard.php';
return ob_get_clean();
}
public function supports(string $viewType): bool
{
return in_array($viewType, self::SUPPORTED_VIEWS, true);
}
/**
* Obtiene los componentes disponibles
*
* @return array<string, array<string, string>>
*/
public function getComponents(): array
{
return [
'top-notification-bar' => [
'id' => 'top-notification-bar',
'label' => 'TopBar',
'icon' => 'bi-megaphone-fill',
],
'navbar' => [
'id' => 'navbar',
'label' => 'Navbar',
'icon' => 'bi-list',
],
'cta-lets-talk' => [
'id' => 'cta-lets-talk',
'label' => "Let's Talk",
'icon' => 'bi-lightning-charge-fill',
],
'hero' => [
'id' => 'hero',
'label' => 'Hero Section',
'icon' => 'bi-image',
],
'featured-image' => [
'id' => 'featured-image',
'label' => 'Featured Image',
'icon' => 'bi-card-image',
],
'table-of-contents' => [
'id' => 'table-of-contents',
'label' => 'Table of Contents',
'icon' => 'bi-list-nested',
],
'cta-box-sidebar' => [
'id' => 'cta-box-sidebar',
'label' => 'CTA Sidebar',
'icon' => 'bi-megaphone',
],
'social-share' => [
'id' => 'social-share',
'label' => 'Social Share',
'icon' => 'bi-share',
],
'cta-post' => [
'id' => 'cta-post',
'label' => 'CTA Post',
'icon' => 'bi-megaphone-fill',
],
'related-post' => [
'id' => 'related-post',
'label' => 'Related Posts',
'icon' => 'bi-grid-3x3-gap',
],
'contact-form' => [
'id' => 'contact-form',
'label' => 'Contact Form',
'icon' => 'bi-envelope-paper',
],
'footer' => [
'id' => 'footer',
'label' => 'Footer',
'icon' => 'bi-layout-text-window-reverse',
],
'theme-settings' => [
'id' => 'theme-settings',
'label' => 'Theme Settings',
'icon' => 'bi-gear',
],
];
}
/**
* Obtiene las configuraciones de un componente
*
* @param string $componentName Nombre del componente
* @return array<string, array<string, mixed>> Configuraciones agrupadas por grupo
*/
public function getComponentSettings(string $componentName): array
{
if ($this->getComponentSettingsUseCase === null) {
return [];
}
return $this->getComponentSettingsUseCase->execute($componentName);
}
/**
* Obtiene el valor de un campo de configuración
*
* @param string $componentName Nombre del componente
* @param string $groupName Nombre del grupo
* @param string $attributeName Nombre del atributo
* @param mixed $default Valor por defecto si no existe
* @return mixed
*/
public function getFieldValue(string $componentName, string $groupName, string $attributeName, mixed $default = null): mixed
{
$settings = $this->getComponentSettings($componentName);
return $settings[$groupName][$attributeName] ?? $default;
}
/**
* Obtiene la clase del FormBuilder para un componente
*
* @param string $componentId ID del componente en kebab-case (ej: 'top-notification-bar')
* @return string Namespace completo del FormBuilder
*/
public function getFormBuilderClass(string $componentId): string
{
// Convertir kebab-case a PascalCase
// 'top-notification-bar' → 'TopNotificationBar'
$className = str_replace('-', '', ucwords($componentId, '-'));
// Construir namespace completo
// ROITheme\Admin\TopNotificationBar\Infrastructure\Ui\TopNotificationBarFormBuilder
return "ROITheme\\Admin\\{$className}\\Infrastructure\\Ui\\{$className}FormBuilder";
}
}

View File

@@ -0,0 +1,137 @@
/**
* Estilos para el Dashboard del Panel de Administración ROI Theme
* Siguiendo especificaciones del Design System
*/
/* Sobrescribir max-width de .card de WordPress */
.wrap.roi-admin-panel .card {
max-width: none !important;
}
/* Fix para switches de Bootstrap - resetear completamente estilos de WordPress */
.wrap.roi-admin-panel .form-switch .form-check-input {
all: unset !important;
/* Restaurar estilos necesarios de Bootstrap */
width: 2em !important;
height: 1em !important;
margin-left: -2.5em !important;
margin-right: 0.5em !important;
background-color: #dee2e6 !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='white'/%3e%3c/svg%3e") !important;
background-position: left center !important;
background-repeat: no-repeat !important;
background-size: contain !important;
border: 1px solid rgba(0, 0, 0, 0.25) !important;
border-radius: 2em !important;
transition: background-position 0.15s ease-in-out !important;
cursor: pointer !important;
flex-shrink: 0 !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
}
.wrap.roi-admin-panel .form-switch .form-check-input:checked {
background-color: #0d6efd !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='white'/%3e%3c/svg%3e") !important;
background-position: right center !important;
border-color: #0d6efd !important;
}
.wrap.roi-admin-panel .form-switch .form-check-input::before,
.wrap.roi-admin-panel .form-switch .form-check-input::after {
display: none !important;
content: none !important;
}
.wrap.roi-admin-panel .form-switch .form-check-input:focus {
outline: 0 !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important;
}
/* Alinear verticalmente los labels con los switches */
.wrap.roi-admin-panel .form-check {
display: flex !important;
align-items: center !important;
}
.wrap.roi-admin-panel .form-check-label {
display: inline-flex !important;
align-items: center !important;
margin-bottom: 0 !important;
padding-top: 0 !important;
}
/* Tabs Navigation */
.nav-tabs-admin {
border-bottom: 2px solid #e9ecef;
}
.nav-tabs-admin .nav-item {
margin-right: 0.1rem;
}
.nav-tabs-admin .nav-link {
color: #6c757d;
border: none;
border-bottom: 3px solid transparent;
padding: 0.3rem 0.3rem;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s ease;
}
.nav-tabs-admin .nav-link i.bi {
margin-right: 0.2rem !important;
font-size: 0.7rem;
}
.nav-tabs-admin .nav-link:hover {
color: #FF8600;
border-bottom-color: #FFB800;
}
.nav-tabs-admin .nav-link.active {
color: #FF8600;
border-bottom-color: #FF8600;
background-color: transparent;
}
/* Tab Content */
.tab-content {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive */
@media (max-width: 991px) {
.nav-tabs-admin {
flex-wrap: wrap;
}
.nav-tabs-admin .nav-link {
font-size: 0.8rem;
padding: 0.35rem 0.5rem;
}
}
@media (max-width: 767px) {
.nav-tabs-admin {
overflow-x: auto;
flex-wrap: nowrap;
}
.nav-tabs-admin .nav-item {
white-space: nowrap;
}
}

View File

@@ -0,0 +1,460 @@
/**
* JavaScript para el Dashboard del Panel de Administración ROI Theme
* Vanilla JavaScript - No frameworks
*/
(function() {
'use strict';
/**
* Inicializa el dashboard cuando el DOM está listo
*/
document.addEventListener('DOMContentLoaded', function() {
initializeTabs();
initializeFormValidation();
initializeButtons();
initializeColorPickers();
});
/**
* Inicializa el sistema de tabs con persistencia en URL
*/
function initializeTabs() {
const tabButtons = document.querySelectorAll('[data-bs-toggle="tab"]');
// Leer parametro admin-tab de la URL al cargar
const urlParams = new URLSearchParams(window.location.search);
const activeTabParam = urlParams.get('admin-tab');
if (activeTabParam) {
// Buscar el boton del tab correspondiente
const targetButton = document.querySelector('[data-bs-target="#' + activeTabParam + 'Tab"]');
if (targetButton) {
// Activar el tab usando Bootstrap API
const tab = new bootstrap.Tab(targetButton);
tab.show();
}
}
// Escuchar cambios de tab para actualizar URL
tabButtons.forEach(function(tabButton) {
tabButton.addEventListener('shown.bs.tab', function(e) {
// Obtener el ID del componente desde data-bs-target
const target = e.target.getAttribute('data-bs-target');
const componentId = target.replace('#', '').replace('Tab', '');
// Actualizar URL sin recargar pagina
updateUrlWithTab(componentId);
});
});
}
/**
* Actualiza la URL con el parametro admin-tab sin recargar la pagina
*
* @param {string} tabId ID del tab activo
*/
function updateUrlWithTab(tabId) {
const url = new URL(window.location.href);
url.searchParams.set('admin-tab', tabId);
window.history.replaceState({}, '', url.toString());
}
/**
* Obtiene el ID del tab activo actualmente
*
* @returns {string|null} ID del componente activo o null
*/
function getActiveTabId() {
const activeTab = document.querySelector('.tab-pane.active');
if (activeTab) {
return activeTab.id.replace('Tab', '');
}
return null;
}
/**
* Inicializa validación de formularios
*/
function initializeFormValidation() {
const forms = document.querySelectorAll('.roi-component-config form');
forms.forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!validateForm(form)) {
e.preventDefault();
showError('Por favor, corrige los errores en el formulario.');
}
});
});
}
/**
* Valida un formulario
*
* @param {HTMLFormElement} form Formulario a validar
* @returns {boolean} True si es válido
*/
function validateForm(form) {
let isValid = true;
const requiredFields = form.querySelectorAll('[required]');
requiredFields.forEach(function(field) {
if (!field.value.trim()) {
field.classList.add('error');
isValid = false;
} else {
field.classList.remove('error');
}
});
return isValid;
}
/**
* Muestra un mensaje de error
*
* @param {string} message Mensaje a mostrar
*/
function showError(message) {
const notice = document.createElement('div');
notice.className = 'notice notice-error is-dismissible';
notice.innerHTML = '<p>' + escapeHtml(message) + '</p>';
const h1 = document.querySelector('.roi-admin-dashboard h1');
if (h1 && h1.nextElementSibling) {
h1.nextElementSibling.after(notice);
}
}
/**
* Escapa HTML para prevenir XSS
*
* @param {string} text Texto a escapar
* @returns {string} Texto escapado
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Inicializa los botones del panel
*/
function initializeButtons() {
// Botón Guardar Cambios
const saveButton = document.getElementById('saveSettings');
if (saveButton) {
saveButton.addEventListener('click', handleSaveSettings);
}
// Botón Cancelar
const cancelButton = document.getElementById('cancelChanges');
if (cancelButton) {
cancelButton.addEventListener('click', handleCancelChanges);
}
// Botones Restaurar valores por defecto (dinámico para todos los componentes)
const resetButtons = document.querySelectorAll('.btn-reset-defaults[data-component]');
resetButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
const componentId = this.getAttribute('data-component');
handleResetDefaults(e, componentId, this);
});
});
}
/**
* Guarda los cambios del formulario
*/
function handleSaveSettings(e) {
e.preventDefault();
// Obtener el tab activo
const activeTab = document.querySelector('.tab-pane.active');
if (!activeTab) {
showNotice('error', 'No hay ningún componente seleccionado.');
return;
}
// Obtener el ID del componente desde el tab
const componentId = activeTab.id.replace('Tab', '');
// Recopilar todos los campos del formulario activo
const formData = collectFormData(activeTab);
// Mostrar loading en el botón
const saveButton = document.getElementById('saveSettings');
const originalText = saveButton.innerHTML;
saveButton.disabled = true;
saveButton.innerHTML = '<i class="bi bi-hourglass-split me-1"></i> Guardando...';
// Enviar por AJAX
fetch(ajaxurl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
action: 'roi_save_component_settings',
nonce: roiAdminDashboard.nonce,
component: componentId,
settings: JSON.stringify(formData)
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotice('success', data.data.message || 'Cambios guardados correctamente.');
} else {
showNotice('error', data.data.message || 'Error al guardar los cambios.');
}
})
.catch(error => {
console.error('Error:', error);
showNotice('error', 'Error de conexión al guardar los cambios.');
})
.finally(() => {
saveButton.disabled = false;
saveButton.innerHTML = originalText;
});
}
/**
* Cancela los cambios y recarga la página
*/
function handleCancelChanges(e) {
e.preventDefault();
showConfirmModal(
'Cancelar cambios',
'¿Descartar todos los cambios no guardados?',
function() {
location.reload();
}
);
}
/**
* Restaura los valores por defecto de un componente
*
* @param {Event} e Evento del click
* @param {string} componentId ID del componente a resetear
* @param {HTMLElement} resetButton Elemento del botón que disparó el evento
*/
function handleResetDefaults(e, componentId, resetButton) {
e.preventDefault();
showConfirmModal(
'Restaurar valores por defecto',
'¿Restaurar todos los valores a los valores por defecto? Esta acción no se puede deshacer.',
function() {
// Mostrar loading en el botón
const originalText = resetButton.innerHTML;
resetButton.disabled = true;
resetButton.innerHTML = '<i class="bi bi-hourglass-split me-1"></i> Restaurando...';
// Enviar por AJAX
fetch(ajaxurl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
action: 'roi_reset_component_defaults',
nonce: roiAdminDashboard.nonce,
component: componentId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotice('success', data.data.message || 'Valores restaurados correctamente.');
// Recargar preservando el tab activo
setTimeout(() => {
const activeTabId = getActiveTabId();
if (activeTabId) {
const url = new URL(window.location.href);
url.searchParams.set('admin-tab', activeTabId);
window.location.href = url.toString();
} else {
location.reload();
}
}, 1500);
} else {
showNotice('error', data.data.message || 'Error al restaurar los valores.');
}
})
.catch(error => {
console.error('Error:', error);
showNotice('error', 'Error de conexión al restaurar los valores.');
})
.finally(() => {
resetButton.disabled = false;
resetButton.innerHTML = originalText;
});
}
);
}
/**
* Recopila los datos del formulario del tab activo
*/
function collectFormData(container) {
const formData = {};
// Inputs de texto, textarea, select, color, number, email, password
const textInputs = container.querySelectorAll('input[type="text"], input[type="url"], input[type="color"], input[type="number"], input[type="email"], input[type="password"], textarea, select');
textInputs.forEach(input => {
if (input.id) {
formData[input.id] = input.value;
}
});
// Checkboxes (switches)
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
if (checkbox.id) {
formData[checkbox.id] = checkbox.checked;
}
});
return formData;
}
/**
* Muestra un toast de Bootstrap
*/
function showNotice(type, message) {
// Mapear tipos
const typeMap = {
'success': { bg: 'success', icon: 'bi-check-circle-fill', text: 'Éxito' },
'error': { bg: 'danger', icon: 'bi-x-circle-fill', text: 'Error' },
'warning': { bg: 'warning', icon: 'bi-exclamation-triangle-fill', text: 'Advertencia' },
'info': { bg: 'info', icon: 'bi-info-circle-fill', text: 'Información' }
};
const config = typeMap[type] || typeMap['info'];
// Crear container de toasts si no existe
let toastContainer = document.getElementById('roiToastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'roiToastContainer';
toastContainer.className = 'toast-container position-fixed start-50 translate-middle-x';
toastContainer.style.top = '60px';
toastContainer.style.zIndex = '999999';
document.body.appendChild(toastContainer);
}
// Crear toast
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="${toastId}" class="toast align-items-center text-white bg-${config.bg} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi ${config.icon} me-2"></i>
<strong>${escapeHtml(message)}</strong>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
// Mostrar toast
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Eliminar del DOM después de ocultarse
toastElement.addEventListener('hidden.bs.toast', function() {
toastElement.remove();
});
}
/**
* Muestra un modal de confirmación de Bootstrap
*/
function showConfirmModal(title, message, onConfirm) {
// Crear modal si no existe
let modal = document.getElementById('roiConfirmModal');
if (!modal) {
const modalHTML = `
<div class="modal fade" id="roiConfirmModal" tabindex="-1" aria-labelledby="roiConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-bottom: none;">
<h5 class="modal-title text-white" id="roiConfirmModalLabel">
<i class="bi bi-question-circle me-2" style="color: #FF8600;"></i>
<span id="roiConfirmModalTitle">Confirmar</span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="roiConfirmModalBody" style="padding: 2rem;">
Mensaje de confirmación
</div>
<div class="modal-footer" style="border-top: 1px solid #dee2e6; padding: 1rem 1.5rem;">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>
Cancelar
</button>
<button type="button" class="btn text-white" id="roiConfirmModalConfirm" style="background-color: #FF8600; border-color: #FF8600;">
<i class="bi bi-check-circle me-1"></i>
Confirmar
</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
modal = document.getElementById('roiConfirmModal');
}
// Actualizar contenido
document.getElementById('roiConfirmModalTitle').textContent = title;
document.getElementById('roiConfirmModalBody').textContent = message;
// Configurar callback
const confirmButton = document.getElementById('roiConfirmModalConfirm');
const newConfirmButton = confirmButton.cloneNode(true);
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
newConfirmButton.addEventListener('click', function() {
const bsModal = bootstrap.Modal.getInstance(modal);
bsModal.hide();
if (typeof onConfirm === 'function') {
onConfirm();
}
});
// Mostrar modal
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
}
/**
* Inicializa los color pickers para mostrar el valor HEX
*/
function initializeColorPickers() {
const colorPickers = document.querySelectorAll('input[type="color"]');
colorPickers.forEach(picker => {
// Elemento donde se muestra el valor HEX
const valueDisplay = document.getElementById(picker.id + 'Value');
if (valueDisplay) {
picker.addEventListener('input', function() {
valueDisplay.textContent = this.value.toUpperCase();
});
}
});
}
})();

View File

@@ -0,0 +1,88 @@
<?php
/**
* ROI Theme - Panel de Administración Principal
*
* @var AdminDashboardRenderer $this
*/
declare(strict_types=1);
// Prevenir acceso directo
if (!defined('ABSPATH')) {
exit;
}
$components = $this->getComponents();
// Determinar tab activo: desde URL o primer componente
$activeComponentId = array_key_first($components);
// Leer parametro admin-tab de la URL con sanitizacion
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Solo lectura de parametro para UI
if (isset($_GET['admin-tab'])) {
$requestedTab = sanitize_text_field(wp_unslash($_GET['admin-tab']));
// Validar que el componente exista
if (array_key_exists($requestedTab, $components)) {
$activeComponentId = $requestedTab;
}
}
?>
<div class="wrap roi-admin-panel">
<!-- Navigation Tabs -->
<ul class="nav nav-tabs nav-tabs-admin mb-0" role="tablist">
<?php foreach ($components as $componentId => $component): ?>
<li class="nav-item" role="presentation">
<button class="nav-link <?php echo $componentId === $activeComponentId ? 'active' : ''; ?>"
data-bs-toggle="tab"
data-bs-target="#<?php echo esc_attr($componentId); ?>Tab"
type="button"
role="tab"
aria-controls="<?php echo esc_attr($componentId); ?>Tab"
aria-selected="<?php echo $componentId === $activeComponentId ? 'true' : 'false'; ?>">
<i class="bi <?php echo esc_attr($component['icon']); ?> me-1"></i>
<?php echo esc_html($component['label']); ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<!-- Tab Content -->
<div class="tab-content mt-3">
<?php foreach ($components as $componentId => $component):
$isActive = ($componentId === $activeComponentId);
$componentSettings = $this->getComponentSettings($componentId);
?>
<!-- Tab: <?php echo esc_html($component['label']); ?> -->
<div class="tab-pane fade <?php echo $isActive ? 'show active' : ''; ?>"
id="<?php echo esc_attr($componentId); ?>Tab"
role="tabpanel">
<?php
// Renderizar FormBuilder del componente
$formBuilderClass = $this->getFormBuilderClass($componentId);
if (class_exists($formBuilderClass)) {
$formBuilder = new $formBuilderClass($this);
echo $formBuilder->buildForm($componentId);
} else {
echo '<p class="text-danger">FormBuilder no encontrado: ' . esc_html($formBuilderClass) . '</p>';
}
?>
</div>
<?php endforeach; ?>
</div>
<!-- Botones Globales Save/Cancel -->
<div class="d-flex justify-content-end gap-2 p-3 rounded border mt-4" style="background-color: #f8f9fa; border-color: #e9ecef !important;">
<button type="button" class="btn btn-outline-secondary" id="cancelChanges">
<i class="bi bi-x-circle me-1"></i>
Cancelar
</button>
<button type="button" id="saveSettings" class="btn fw-semibold text-white" style="background-color: #FF8600; border-color: #FF8600;">
<i class="bi bi-check-circle me-1"></i>
Guardar Cambios
</button>
</div>
</div><!-- /wrap -->

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Navbar
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class NavbarFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'navbar';
}
public function getFieldMapping(): array
{
return [
// Visibility
'navbarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'navbarShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'navbarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'],
// Layout
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],
'navbarPaddingVertical' => ['group' => 'layout', 'attribute' => 'padding_vertical'],
'navbarZIndex' => ['group' => 'layout', 'attribute' => 'z_index'],
// Behavior
'navbarMenuLocation' => ['group' => 'behavior', 'attribute' => 'menu_location'],
'navbarCustomMenuId' => ['group' => 'behavior', 'attribute' => 'custom_menu_id'],
'navbarEnableDropdowns' => ['group' => 'behavior', 'attribute' => 'enable_dropdowns'],
'navbarMobileBreakpoint' => ['group' => 'behavior', 'attribute' => 'mobile_breakpoint'],
// Media (Logo/Marca)
'navbarShowBrand' => ['group' => 'media', 'attribute' => 'show_brand'],
'navbarUseLogo' => ['group' => 'media', 'attribute' => 'use_logo'],
'navbarLogoUrl' => ['group' => 'media', 'attribute' => 'logo_url'],
'navbarLogoHeight' => ['group' => 'media', 'attribute' => 'logo_height'],
'navbarBrandText' => ['group' => 'media', 'attribute' => 'brand_text'],
'navbarBrandFontSize' => ['group' => 'media', 'attribute' => 'brand_font_size'],
'navbarBrandColor' => ['group' => 'media', 'attribute' => 'brand_color'],
'navbarBrandHoverColor' => ['group' => 'media', 'attribute' => 'brand_hover_color'],
// Links
'linksTextColor' => ['group' => 'links', 'attribute' => 'text_color'],
'linksHoverColor' => ['group' => 'links', 'attribute' => 'hover_color'],
'linksActiveColor' => ['group' => 'links', 'attribute' => 'active_color'],
'linksFontSize' => ['group' => 'links', 'attribute' => 'font_size'],
'linksFontWeight' => ['group' => 'links', 'attribute' => 'font_weight'],
'linksPadding' => ['group' => 'links', 'attribute' => 'padding'],
'linksBorderRadius' => ['group' => 'links', 'attribute' => 'border_radius'],
'linksShowUnderline' => ['group' => 'links', 'attribute' => 'show_underline_effect'],
'linksUnderlineColor' => ['group' => 'links', 'attribute' => 'underline_color'],
// Visual Effects (Dropdown)
'dropdownBgColor' => ['group' => 'visual_effects', 'attribute' => 'background_color'],
'dropdownBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'border_radius'],
'dropdownShadow' => ['group' => 'visual_effects', 'attribute' => 'shadow'],
'dropdownItemColor' => ['group' => 'visual_effects', 'attribute' => 'item_color'],
'dropdownItemHoverBg' => ['group' => 'visual_effects', 'attribute' => 'item_hover_background'],
'dropdownItemPadding' => ['group' => 'visual_effects', 'attribute' => 'item_padding'],
'dropdownMaxHeight' => ['group' => 'visual_effects', 'attribute' => 'dropdown_max_height'],
// Colors (Navbar styles)
'navbarBgColor' => ['group' => 'colors', 'attribute' => 'background_color'],
'navbarBoxShadow' => ['group' => 'colors', 'attribute' => 'box_shadow'],
];
}
}

View File

@@ -0,0 +1,517 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
final class NavbarFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
// Header
$html .= $this->buildHeader($componentId);
// Layout 2 columnas
$html .= '<div class="row g-3">';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildLayoutGroup($componentId);
$html .= $this->buildBehaviorGroup($componentId);
$html .= $this->buildMediaGroup($componentId);
$html .= ' </div>';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildLinksGroup($componentId);
$html .= $this->buildVisualEffectsGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-menu-button-wide me-2" style="color: #FF8600;"></i>';
$html .= ' Configuración de Navbar';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Personaliza el menú de navegación principal del sitio';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="navbar">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Activación y Visibilidad';
$html .= ' </h5>';
// Switch: Enabled
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarEnabled" name="visibility[is_enabled]" ';
$html .= checked($enabled, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarEnabled">';
$html .= ' <strong>Activar Navbar</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Show on Mobile
$showMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarShowMobile" name="visibility[show_on_mobile]" ';
$html .= checked($showMobile, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarShowMobile">';
$html .= ' <strong>Mostrar en Mobile</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Show on Desktop
$showDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarShowDesktop" name="visibility[show_on_desktop]" ';
$html .= checked($showDesktop, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarShowDesktop">';
$html .= ' <strong>Mostrar en Desktop</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
$html .= ' <select id="navbarShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
$html .= ' </div>';
// Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarSticky" name="visibility[sticky_enabled]" ';
$html .= checked($sticky, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarSticky">';
$html .= ' <strong>Navbar fijo (sticky)</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildLayoutGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-layout-sidebar me-2" style="color: #FF8600;"></i>';
$html .= ' Layout y Estructura';
$html .= ' </h5>';
// Container Type
$containerType = $this->renderer->getFieldValue($componentId, 'layout', 'container_type', 'container');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarContainerType" class="form-label small mb-1 fw-semibold">Tipo de contenedor</label>';
$html .= ' <select id="navbarContainerType" name="layout[container_type]" class="form-select form-select-sm">';
$html .= ' <option value="container" ' . selected($containerType, 'container', false) . '>Container (ancho fijo)</option>';
$html .= ' <option value="container-fluid" ' . selected($containerType, 'container-fluid', false) . '>Container Fluid (ancho completo)</option>';
$html .= ' </select>';
$html .= ' </div>';
// Padding Vertical
$paddingVertical = $this->renderer->getFieldValue($componentId, 'layout', 'padding_vertical', '0.75rem 0');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarPaddingVertical" class="form-label small mb-1 fw-semibold">Padding vertical</label>';
$html .= ' <input type="text" id="navbarPaddingVertical" name="layout[padding_vertical]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($paddingVertical) . '" placeholder="0.75rem 0">';
$html .= ' </div>';
// Z-index
$zIndex = $this->renderer->getFieldValue($componentId, 'layout', 'z_index', '1030');
$html .= ' <div class="mb-0">';
$html .= ' <label for="navbarZIndex" class="form-label small mb-1 fw-semibold">Z-index</label>';
$html .= ' <input type="text" id="navbarZIndex" name="layout[z_index]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($zIndex) . '" placeholder="1030">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildBehaviorGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-list me-2" style="color: #FF8600;"></i>';
$html .= ' Configuración del Menú';
$html .= ' </h5>';
// Menu Location
$menuLocation = $this->renderer->getFieldValue($componentId, 'behavior', 'menu_location', 'primary');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarMenuLocation" class="form-label small mb-1 fw-semibold">Ubicación del menú</label>';
$html .= ' <select id="navbarMenuLocation" name="behavior[menu_location]" class="form-select form-select-sm">';
$html .= ' <option value="primary" ' . selected($menuLocation, 'primary', false) . '>Menú Principal</option>';
$html .= ' <option value="secondary" ' . selected($menuLocation, 'secondary', false) . '>Menú Secundario</option>';
$html .= ' <option value="custom" ' . selected($menuLocation, 'custom', false) . '>Menú personalizado</option>';
$html .= ' </select>';
$html .= ' </div>';
// Custom Menu ID
$customMenuId = $this->renderer->getFieldValue($componentId, 'behavior', 'custom_menu_id', '0');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarCustomMenuId" class="form-label small mb-1 fw-semibold">ID del menú personalizado</label>';
$html .= ' <input type="text" id="navbarCustomMenuId" name="behavior[custom_menu_id]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($customMenuId) . '" placeholder="0">';
$html .= ' </div>';
// Enable Dropdowns
$enableDropdowns = $this->renderer->getFieldValue($componentId, 'behavior', 'enable_dropdowns', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarEnableDropdowns" name="behavior[enable_dropdowns]" ';
$html .= checked($enableDropdowns, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarEnableDropdowns">';
$html .= ' <strong>Habilitar submenús desplegables</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Mobile Breakpoint
$mobileBreakpoint = $this->renderer->getFieldValue($componentId, 'behavior', 'mobile_breakpoint', 'lg');
$html .= ' <div class="mb-0">';
$html .= ' <label for="navbarMobileBreakpoint" class="form-label small mb-1 fw-semibold">Breakpoint para menú móvil</label>';
$html .= ' <select id="navbarMobileBreakpoint" name="behavior[mobile_breakpoint]" class="form-select form-select-sm">';
$html .= ' <option value="sm" ' . selected($mobileBreakpoint, 'sm', false) . '>Small (576px)</option>';
$html .= ' <option value="md" ' . selected($mobileBreakpoint, 'md', false) . '>Medium (768px)</option>';
$html .= ' <option value="lg" ' . selected($mobileBreakpoint, 'lg', false) . '>Large (992px)</option>';
$html .= ' <option value="xl" ' . selected($mobileBreakpoint, 'xl', false) . '>Extra Large (1200px)</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildMediaGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-image me-2" style="color: #FF8600;"></i>';
$html .= ' Logo/Marca';
$html .= ' </h5>';
// Show Brand
$showBrand = $this->renderer->getFieldValue($componentId, 'media', 'show_brand', false);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarShowBrand" name="media[show_brand]" ';
$html .= checked($showBrand, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarShowBrand">';
$html .= ' <strong>Mostrar logo/marca</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Use Logo
$useLogo = $this->renderer->getFieldValue($componentId, 'media', 'use_logo', false);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarUseLogo" name="media[use_logo]" ';
$html .= checked($useLogo, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarUseLogo">';
$html .= ' <strong>Usar logo (imagen)</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Logo URL
$logoUrl = $this->renderer->getFieldValue($componentId, 'media', 'logo_url', '');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarLogoUrl" class="form-label small mb-1 fw-semibold">URL del logo</label>';
$html .= ' <input type="text" id="navbarLogoUrl" name="media[logo_url]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($logoUrl) . '" placeholder="https://...">';
$html .= ' </div>';
// Logo Height
$logoHeight = $this->renderer->getFieldValue($componentId, 'media', 'logo_height', '40px');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarLogoHeight" class="form-label small mb-1 fw-semibold">Altura del logo</label>';
$html .= ' <input type="text" id="navbarLogoHeight" name="media[logo_height]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($logoHeight) . '" placeholder="40px">';
$html .= ' </div>';
// Brand Text
$brandText = $this->renderer->getFieldValue($componentId, 'media', 'brand_text', 'Mi Sitio');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarBrandText" class="form-label small mb-1 fw-semibold">Texto de la marca</label>';
$html .= ' <input type="text" id="navbarBrandText" name="media[brand_text]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($brandText) . '" maxlength="50">';
$html .= ' </div>';
// Brand Font Size
$brandFontSize = $this->renderer->getFieldValue($componentId, 'media', 'brand_font_size', '1.5rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarBrandFontSize" class="form-label small mb-1 fw-semibold">Tamaño de fuente</label>';
$html .= ' <input type="text" id="navbarBrandFontSize" name="media[brand_font_size]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($brandFontSize) . '" placeholder="1.5rem">';
$html .= ' </div>';
// Brand Color
$brandColor = $this->renderer->getFieldValue($componentId, 'media', 'brand_color', '#FFFFFF');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarBrandColor" class="form-label small mb-1 fw-semibold">Color de la marca</label>';
$html .= ' <input type="color" id="navbarBrandColor" name="media[brand_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($brandColor) . '">';
$html .= ' </div>';
// Brand Hover Color
$brandHoverColor = $this->renderer->getFieldValue($componentId, 'media', 'brand_hover_color', '#FF8600');
$html .= ' <div class="mb-0">';
$html .= ' <label for="navbarBrandHoverColor" class="form-label small mb-1 fw-semibold">Color hover de la marca</label>';
$html .= ' <input type="color" id="navbarBrandHoverColor" name="media[brand_hover_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($brandHoverColor) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildLinksGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-link-45deg me-2" style="color: #FF8600;"></i>';
$html .= ' Estilos de Enlaces';
$html .= ' </h5>';
// Text Color
$textColor = $this->renderer->getFieldValue($componentId, 'links', 'text_color', '#FFFFFF');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksTextColor" class="form-label small mb-1 fw-semibold">Color del texto</label>';
$html .= ' <input type="color" id="linksTextColor" name="links[text_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($textColor) . '">';
$html .= ' </div>';
// Hover Color
$hoverColor = $this->renderer->getFieldValue($componentId, 'links', 'hover_color', '#FF8600');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksHoverColor" class="form-label small mb-1 fw-semibold">Color hover</label>';
$html .= ' <input type="color" id="linksHoverColor" name="links[hover_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($hoverColor) . '">';
$html .= ' </div>';
// Active Color
$activeColor = $this->renderer->getFieldValue($componentId, 'links', 'active_color', '#FF8600');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksActiveColor" class="form-label small mb-1 fw-semibold">Color del item activo</label>';
$html .= ' <input type="color" id="linksActiveColor" name="links[active_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($activeColor) . '">';
$html .= ' </div>';
// Font Size
$fontSize = $this->renderer->getFieldValue($componentId, 'links', 'font_size', '0.9rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksFontSize" class="form-label small mb-1 fw-semibold">Tamaño de fuente</label>';
$html .= ' <input type="text" id="linksFontSize" name="links[font_size]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fontSize) . '" placeholder="0.9rem">';
$html .= ' </div>';
// Font Weight
$fontWeight = $this->renderer->getFieldValue($componentId, 'links', 'font_weight', '500');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksFontWeight" class="form-label small mb-1 fw-semibold">Grosor de fuente</label>';
$html .= ' <input type="text" id="linksFontWeight" name="links[font_weight]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fontWeight) . '" placeholder="500">';
$html .= ' </div>';
// Padding
$padding = $this->renderer->getFieldValue($componentId, 'links', 'padding', '0.5rem 0.65rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksPadding" class="form-label small mb-1 fw-semibold">Padding de enlaces</label>';
$html .= ' <input type="text" id="linksPadding" name="links[padding]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($padding) . '" placeholder="0.5rem 0.65rem">';
$html .= ' </div>';
// Border Radius
$borderRadius = $this->renderer->getFieldValue($componentId, 'links', 'border_radius', '4px');
$html .= ' <div class="mb-2">';
$html .= ' <label for="linksBorderRadius" class="form-label small mb-1 fw-semibold">Border radius hover</label>';
$html .= ' <input type="text" id="linksBorderRadius" name="links[border_radius]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '" placeholder="4px">';
$html .= ' </div>';
// Show Underline Effect
$showUnderline = $this->renderer->getFieldValue($componentId, 'links', 'show_underline_effect', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="linksShowUnderline" name="links[show_underline_effect]" ';
$html .= checked($showUnderline, true, false) . '>';
$html .= ' <label class="form-check-label small" for="linksShowUnderline">';
$html .= ' <strong>Mostrar efecto de subrayado</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Underline Color
$underlineColor = $this->renderer->getFieldValue($componentId, 'links', 'underline_color', '#FF8600');
$html .= ' <div class="mb-0">';
$html .= ' <label for="linksUnderlineColor" class="form-label small mb-1 fw-semibold">Color del subrayado</label>';
$html .= ' <input type="color" id="linksUnderlineColor" name="links[underline_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($underlineColor) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisualEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-chevron-down me-2" style="color: #FF8600;"></i>';
$html .= ' Estilos de Dropdown';
$html .= ' </h5>';
// Background Color
$bgColor = $this->renderer->getFieldValue($componentId, 'visual_effects', 'background_color', '#FFFFFF');
$html .= ' <div class="mb-2">';
$html .= ' <label for="dropdownBgColor" class="form-label small mb-1 fw-semibold">Fondo de dropdown</label>';
$html .= ' <input type="color" id="dropdownBgColor" name="visual_effects[background_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($bgColor) . '">';
$html .= ' </div>';
// Border Radius
$borderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_radius', '8px');
$html .= ' <div class="mb-2">';
$html .= ' <label for="dropdownBorderRadius" class="form-label small mb-1 fw-semibold">Border radius</label>';
$html .= ' <input type="text" id="dropdownBorderRadius" name="visual_effects[border_radius]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '" placeholder="8px">';
$html .= ' </div>';
// Shadow
$shadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'shadow', '0 8px 24px rgba(0, 0, 0, 0.12)');
$html .= ' <div class="mb-2">';
$html .= ' <label for="dropdownShadow" class="form-label small mb-1 fw-semibold">Sombra del dropdown</label>';
$html .= ' <input type="text" id="dropdownShadow" name="visual_effects[shadow]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($shadow) . '">';
$html .= ' </div>';
// Item Color
$itemColor = $this->renderer->getFieldValue($componentId, 'visual_effects', 'item_color', '#495057');
$html .= ' <div class="mb-2">';
$html .= ' <label for="dropdownItemColor" class="form-label small mb-1 fw-semibold">Color de items</label>';
$html .= ' <input type="color" id="dropdownItemColor" name="visual_effects[item_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($itemColor) . '">';
$html .= ' </div>';
// Item Hover Background
$itemHoverBg = $this->renderer->getFieldValue($componentId, 'visual_effects', 'item_hover_background', 'rgba(255, 133, 0, 0.1)');
$html .= ' <div class="mb-2">';
$html .= ' <label for="dropdownItemHoverBg" class="form-label small mb-1 fw-semibold">Fondo hover de items</label>';
$html .= ' <input type="text" id="dropdownItemHoverBg" name="visual_effects[item_hover_background]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($itemHoverBg) . '">';
$html .= ' </div>';
// Item Padding
$itemPadding = $this->renderer->getFieldValue($componentId, 'visual_effects', 'item_padding', '0.625rem 1.25rem');
$html .= ' <div class="mb-2">';
$html .= ' <label for="dropdownItemPadding" class="form-label small mb-1 fw-semibold">Padding de items</label>';
$html .= ' <input type="text" id="dropdownItemPadding" name="visual_effects[item_padding]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($itemPadding) . '" placeholder="0.625rem 1.25rem">';
$html .= ' </div>';
// Dropdown Max Height
$dropdownMaxHeight = $this->renderer->getFieldValue($componentId, 'visual_effects', 'dropdown_max_height', '300px');
$html .= ' <div class="mb-0">';
$html .= ' <label for="dropdownMaxHeight" class="form-label small mb-1 fw-semibold">Altura máxima del dropdown</label>';
$html .= ' <input type="text" id="dropdownMaxHeight" name="visual_effects[dropdown_max_height]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($dropdownMaxHeight) . '" placeholder="300px">';
$html .= ' <small class="text-muted">Si se excede, aparece scroll vertical</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Estilos del Navbar';
$html .= ' </h5>';
// Background Color
$navbarBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'background_color', '#1e3a5f');
$html .= ' <div class="mb-2">';
$html .= ' <label for="navbarBgColor" class="form-label small mb-1 fw-semibold">Color de fondo</label>';
$html .= ' <input type="color" id="navbarBgColor" name="colors[background_color]" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($navbarBgColor) . '">';
$html .= ' </div>';
// Box Shadow
$boxShadow = $this->renderer->getFieldValue($componentId, 'colors', 'box_shadow', '0 4px 12px rgba(30, 58, 95, 0.15)');
$html .= ' <div class="mb-0">';
$html .= ' <label for="navbarBoxShadow" class="form-label small mb-1 fw-semibold">Sombra del navbar</label>';
$html .= ' <input type="text" id="navbarBoxShadow" name="colors[box_shadow]" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,544 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Navbar - Preview de Diseño</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background-color: #f0f0f1;
padding: 20px;
}
</style>
</head>
<body>
<!-- ============================================================
TAB: NAVBAR CONFIGURATION
============================================================ -->
<div class="tab-pane fade show active" id="navbarTab" role="tabpanel">
<!-- ========================================
PATRÓN 1: HEADER CON GRADIENTE
======================================== -->
<div class="rounded p-4 mb-4 shadow text-white" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h3 class="h4 mb-1 fw-bold">
<i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>
Configuración de Navbar
</h3>
<p class="mb-0 small" style="opacity: 0.85;">
Personaliza el menú de navegación principal del sitio
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-light" id="resetNavbarDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- ========================================
PATRÓN 2: LAYOUT 2 COLUMNAS
======================================== -->
<div class="row g-3">
<div class="col-lg-6">
<!-- ========================================
GRUPO 1: ACTIVACIÓN Y VISIBILIDAD (OBLIGATORIO)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>
Activación y Visibilidad
</h5>
<!-- ⚠️ PATRÓN 4: SWITCHES VERTICALES CON ICONOS (3 OBLIGATORIOS) -->
<!-- Switch 1: Enabled (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarEnabled" checked>
<label class="form-check-label small" for="navbarEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Activar Navbar</strong>
</label>
</div>
</div>
<!-- Switch 2: Show on Mobile (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarShowOnMobile" checked>
<label class="form-check-label small" for="navbarShowOnMobile" style="color: #495057;">
<i class="bi bi-phone me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>
</label>
</div>
</div>
<!-- Switch 3: Show on Desktop (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarShowOnDesktop" checked>
<label class="form-check-label small" for="navbarShowOnDesktop" style="color: #495057;">
<i class="bi bi-display me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>
</label>
</div>
</div>
<!-- Campo adicional del schema: show_on_pages (select) -->
<div class="mb-2 mt-3">
<label for="navbarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>
Mostrar en
</label>
<select id="navbarShowOnPages" class="form-select form-select-sm">
<option value="all" selected>Todas las páginas</option>
<option value="home">Solo página de inicio</option>
<option value="posts">Solo posts individuales</option>
<option value="pages">Solo páginas</option>
</select>
</div>
<!-- Switch 5: Sticky Enabled -->
<div class="mb-0 mt-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarStickyEnabled" checked>
<label class="form-check-label small" for="navbarStickyEnabled" style="color: #495057;">
<i class="bi bi-pin-angle me-1" style="color: #FF8600;"></i>
<strong>Navbar fijo (sticky)</strong>
</label>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 2: LAYOUT Y ESTRUCTURA
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-columns-gap me-2" style="color: #FF8600;"></i>
Layout y Estructura
</h5>
<!-- container_type (select) -->
<div class="mb-2">
<label for="navbarContainerType" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-box me-1" style="color: #FF8600;"></i>
Tipo de contenedor
</label>
<select id="navbarContainerType" class="form-select form-select-sm">
<option value="container" selected>Container (ancho fijo)</option>
<option value="container-fluid">Container Fluid (ancho completo)</option>
</select>
</div>
<!-- padding_vertical + z_index (compactados) -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="navbarPaddingVertical" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-vertical me-1" style="color: #FF8600;"></i>
Padding vertical
</label>
<input type="text" id="navbarPaddingVertical" class="form-control form-control-sm" value="0.75rem 0">
</div>
<div class="col-6">
<label for="navbarZIndex" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-layers me-1" style="color: #FF8600;"></i>
Z-index
</label>
<input type="number" id="navbarZIndex" class="form-control form-control-sm" value="1030" min="1" max="9999">
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 3: CONFIGURACIÓN DEL MENÚ
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-gear me-2" style="color: #FF8600;"></i>
Configuración del Menú
</h5>
<!-- menu_location + custom_menu_id (compactados) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarMenuLocation" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-pin-map me-1" style="color: #FF8600;"></i>
Ubicación del menú
</label>
<select id="navbarMenuLocation" class="form-select form-select-sm">
<option value="primary" selected>Menú Principal</option>
<option value="secondary">Menú Secundario</option>
<option value="custom">Menú personalizado</option>
</select>
</div>
<div class="col-6">
<label for="navbarCustomMenuId" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hash me-1" style="color: #FF8600;"></i>
ID del menú
</label>
<input type="number" id="navbarCustomMenuId" class="form-control form-control-sm" value="0" min="0">
</div>
</div>
<!-- enable_dropdowns (switch) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarEnableDropdowns" checked>
<label class="form-check-label small" for="navbarEnableDropdowns" style="color: #495057;">
<i class="bi bi-chevron-down me-1" style="color: #FF8600;"></i>
<strong>Habilitar submenús desplegables</strong>
</label>
</div>
</div>
<!-- mobile_breakpoint (select) -->
<div class="mb-0">
<label for="navbarMobileBreakpoint" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-phone-landscape me-1" style="color: #FF8600;"></i>
Breakpoint para menú móvil
</label>
<select id="navbarMobileBreakpoint" class="form-select form-select-sm">
<option value="sm">Small (576px)</option>
<option value="md">Medium (768px)</option>
<option value="lg" selected>Large (992px)</option>
<option value="xl">Extra Large (1200px)</option>
</select>
</div>
</div>
</div>
<!-- ========================================
GRUPO 4: LOGO/MARCA
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-award me-2" style="color: #FF8600;"></i>
Logo/Marca
</h5>
<!-- show_brand (switch) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarShowBrand">
<label class="form-check-label small" for="navbarShowBrand" style="color: #495057;">
<i class="bi bi-eye me-1" style="color: #FF8600;"></i>
<strong>Mostrar logo/marca</strong>
</label>
</div>
</div>
<!-- use_logo (switch) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarUseLogo">
<label class="form-check-label small" for="navbarUseLogo" style="color: #495057;">
<i class="bi bi-image me-1" style="color: #FF8600;"></i>
<strong>Usar logo (imagen)</strong>
</label>
</div>
<small class="text-muted d-block ms-4 mt-1">Usa una imagen en lugar de texto</small>
</div>
<!-- logo_url + logo_height (compactados) -->
<div class="row g-2 mb-2">
<div class="col-8">
<label for="navbarLogoUrl" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>
URL del logo
</label>
<input type="url" id="navbarLogoUrl" class="form-control form-control-sm" placeholder="https://...">
</div>
<div class="col-4">
<label for="navbarLogoHeight" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-vertical me-1" style="color: #FF8600;"></i>
Altura
</label>
<input type="text" id="navbarLogoHeight" class="form-control form-control-sm" value="40px">
</div>
</div>
<!-- brand_text -->
<div class="mb-2">
<label for="navbarBrandText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Texto de la marca
</label>
<input type="text" id="navbarBrandText" class="form-control form-control-sm" value="Mi Sitio" maxlength="50">
<small class="text-muted">Se muestra si no hay logo</small>
</div>
<!-- brand_font_size + brand_color (compactados) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarBrandFontSize" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type me-1" style="color: #FF8600;"></i>
Tamaño fuente
</label>
<input type="text" id="navbarBrandFontSize" class="form-control form-control-sm" value="1.5rem">
</div>
<div class="col-6">
<label for="navbarBrandColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-palette me-1" style="color: #FF8600;"></i>
Color
</label>
<input type="color" id="navbarBrandColor" class="form-control form-control-color w-100" value="#FFFFFF">
</div>
</div>
<!-- brand_hover_color -->
<div class="mb-0">
<label for="navbarBrandHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>
Color hover
</label>
<input type="color" id="navbarBrandHoverColor" class="form-control form-control-color w-100" value="#FF8600">
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<!-- ========================================
GRUPO 5: ESTILOS DEL NAVBAR
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-paint-bucket me-2" style="color: #FF8600;"></i>
Estilos del Navbar
</h5>
<!-- background_color -->
<div class="mb-2">
<label for="navbarBackgroundColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-palette me-1" style="color: #FF8600;"></i>
Color de fondo
</label>
<input type="color" id="navbarBackgroundColor" class="form-control form-control-color w-100" value="#1e3a5f">
<small class="text-muted d-block mt-1" id="navbarBackgroundColorValue">#1E3A5F</small>
</div>
<!-- box_shadow -->
<div class="mb-0">
<label for="navbarBoxShadow" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-droplet me-1" style="color: #FF8600;"></i>
Sombra del navbar
</label>
<input type="text" id="navbarBoxShadow" class="form-control form-control-sm" value="0 4px 12px rgba(30, 58, 95, 0.15)">
<small class="text-muted">Sombra CSS (ej: 0 4px 12px rgba(0,0,0,0.15))</small>
</div>
</div>
</div>
<!-- ========================================
GRUPO 6: ESTILOS DE ENLACES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-link-45deg me-2" style="color: #FF8600;"></i>
Estilos de Enlaces
</h5>
<!-- COLOR PICKERS EN GRID 3 COLORES -->
<div class="row g-2 mb-2">
<div class="col-4">
<label for="navbarTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Color texto
</label>
<input type="color" id="navbarTextColor" class="form-control form-control-color w-100" value="#FFFFFF">
<small class="text-muted d-block mt-1" id="navbarTextColorValue">#FFFFFF</small>
</div>
<div class="col-4">
<label for="navbarHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>
Color hover
</label>
<input type="color" id="navbarHoverColor" class="form-control form-control-color w-100" value="#FF8600">
<small class="text-muted d-block mt-1" id="navbarHoverColorValue">#FF8600</small>
</div>
<div class="col-4">
<label for="navbarActiveColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-check-circle me-1" style="color: #FF8600;"></i>
Color activo
</label>
<input type="color" id="navbarActiveColor" class="form-control form-control-color w-100" value="#FF8600">
<small class="text-muted d-block mt-1" id="navbarActiveColorValue">#FF8600</small>
</div>
</div>
<!-- font_size + font_weight (compactados) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarFontSize" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type me-1" style="color: #FF8600;"></i>
Tamaño fuente
</label>
<input type="text" id="navbarFontSize" class="form-control form-control-sm" value="0.9rem">
</div>
<div class="col-6">
<label for="navbarFontWeight" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Grosor fuente
</label>
<input type="number" id="navbarFontWeight" class="form-control form-control-sm" value="500" min="100" max="900" step="100">
</div>
</div>
<!-- padding + border_radius (compactados) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarLinkPadding" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-bounding-box me-1" style="color: #FF8600;"></i>
Padding
</label>
<input type="text" id="navbarLinkPadding" class="form-control form-control-sm" value="0.5rem 0.65rem">
</div>
<div class="col-6">
<label for="navbarBorderRadius" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-square me-1" style="color: #FF8600;"></i>
Border radius
</label>
<input type="text" id="navbarBorderRadius" class="form-control form-control-sm" value="4px">
</div>
</div>
<!-- show_underline_effect (switch) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarShowUnderlineEffect" checked>
<label class="form-check-label small" for="navbarShowUnderlineEffect" style="color: #495057;">
<i class="bi bi-dash-lg me-1" style="color: #FF8600;"></i>
<strong>Mostrar efecto de subrayado</strong>
</label>
</div>
</div>
<!-- underline_color -->
<div class="mb-0">
<label for="navbarUnderlineColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-palette me-1" style="color: #FF8600;"></i>
Color del subrayado
</label>
<input type="color" id="navbarUnderlineColor" class="form-control form-control-color w-100" value="#FF8600">
<small class="text-muted d-block mt-1" id="navbarUnderlineColorValue">#FF8600</small>
</div>
</div>
</div>
<!-- ========================================
GRUPO 7: ESTILOS DE DROPDOWN
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-chevron-down me-2" style="color: #FF8600;"></i>
Estilos de Dropdown
</h5>
<!-- background_color -->
<div class="mb-2">
<label for="navbarDropdownBackground" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Fondo dropdown
</label>
<input type="color" id="navbarDropdownBackground" class="form-control form-control-color w-100" value="#FFFFFF">
<small class="text-muted d-block mt-1" id="navbarDropdownBackgroundValue">#FFFFFF</small>
</div>
<!-- border_radius + shadow (compactados) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarDropdownBorderRadius" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-square me-1" style="color: #FF8600;"></i>
Border radius
</label>
<input type="text" id="navbarDropdownBorderRadius" class="form-control form-control-sm" value="8px">
</div>
<div class="col-6">
<label for="navbarDropdownShadow" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-droplet me-1" style="color: #FF8600;"></i>
Sombra
</label>
<input type="text" id="navbarDropdownShadow" class="form-control form-control-sm" value="0 8px 24px rgba(0,0,0,0.12)">
</div>
</div>
<!-- item_color + item_hover_background -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarDropdownItemColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Color items
</label>
<input type="color" id="navbarDropdownItemColor" class="form-control form-control-color w-100" value="#495057">
</div>
<div class="col-6">
<label for="navbarDropdownItemHoverBg" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Fondo hover
</label>
<input type="color" id="navbarDropdownItemHoverBg" class="form-control form-control-color w-100" value="#FFF5EB">
</div>
</div>
<!-- item_padding -->
<div class="mb-0">
<label for="navbarDropdownItemPadding" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-bounding-box me-1" style="color: #FF8600;"></i>
Padding de items
</label>
<input type="text" id="navbarDropdownItemPadding" class="form-control form-control-sm" value="0.625rem 1.25rem">
<small class="text-muted">Espaciado interno de items (ej: 0.625rem 1.25rem)</small>
</div>
</div>
</div>
</div>
</div>
</div><!-- /tab-pane -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Actualizar valores HEX de color pickers
document.querySelectorAll('input[type="color"]').forEach(picker => {
const valueDisplay = document.getElementById(picker.id + 'Value');
if (valueDisplay) {
picker.addEventListener('input', function() {
valueDisplay.textContent = this.value.toUpperCase();
});
}
});
// Simular reset button
document.getElementById('resetNavbarDefaults').addEventListener('click', function() {
if (confirm('¿Restaurar todos los valores a los valores por defecto?')) {
alert('En producción, esto restauraría los valores del schema JSON');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Related Post
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*
* NOTA: Este componente NO tenia mapeos en AdminAjaxHandler
* (era el unico componente roto - 35 campos no se guardaban)
*/
final class RelatedPostFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'related-post';
}
public function getFieldMapping(): array
{
return [
// Visibility
'relatedPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'relatedPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'relatedPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'relatedPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
'relatedPostPerPage' => ['group' => 'content', 'attribute' => 'posts_per_page'],
'relatedPostOrderby' => ['group' => 'content', 'attribute' => 'orderby'],
'relatedPostOrder' => ['group' => 'content', 'attribute' => 'order'],
'relatedPostShowPagination' => ['group' => 'content', 'attribute' => 'show_pagination'],
// Layout
'relatedPostColsDesktop' => ['group' => 'layout', 'attribute' => 'columns_desktop'],
'relatedPostColsTablet' => ['group' => 'layout', 'attribute' => 'columns_tablet'],
'relatedPostColsMobile' => ['group' => 'layout', 'attribute' => 'columns_mobile'],
// Typography
'relatedPostSectionTitleSize' => ['group' => 'typography', 'attribute' => 'section_title_size'],
'relatedPostSectionTitleWeight' => ['group' => 'typography', 'attribute' => 'section_title_weight'],
'relatedPostCardTitleSize' => ['group' => 'typography', 'attribute' => 'card_title_size'],
'relatedPostCardTitleWeight' => ['group' => 'typography', 'attribute' => 'card_title_weight'],
// Colors
'relatedPostSectionTitleColor' => ['group' => 'colors', 'attribute' => 'section_title_color'],
'relatedPostCardBgColor' => ['group' => 'colors', 'attribute' => 'card_bg_color'],
'relatedPostCardTitleColor' => ['group' => 'colors', 'attribute' => 'card_title_color'],
'relatedPostCardHoverBgColor' => ['group' => 'colors', 'attribute' => 'card_hover_bg_color'],
'relatedPostPaginationBgColor' => ['group' => 'colors', 'attribute' => 'pagination_bg_color'],
'relatedPostPaginationTextColor' => ['group' => 'colors', 'attribute' => 'pagination_text_color'],
'relatedPostPaginationActiveBg' => ['group' => 'colors', 'attribute' => 'pagination_active_bg'],
'relatedPostPaginationActiveText' => ['group' => 'colors', 'attribute' => 'pagination_active_text'],
// Spacing
'relatedPostSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
'relatedPostSectionMarginBottom' => ['group' => 'spacing', 'attribute' => 'section_margin_bottom'],
'relatedPostTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'relatedPostGridGap' => ['group' => 'spacing', 'attribute' => 'grid_gap'],
'relatedPostCardPadding' => ['group' => 'spacing', 'attribute' => 'card_padding'],
'relatedPostPaginationMarginTop' => ['group' => 'spacing', 'attribute' => 'pagination_margin_top'],
// Visual Effects
'relatedPostCardBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'card_border_radius'],
'relatedPostCardShadow' => ['group' => 'visual_effects', 'attribute' => 'card_shadow'],
'relatedPostCardHoverShadow' => ['group' => 'visual_effects', 'attribute' => 'card_hover_shadow'],
'relatedPostCardTransition' => ['group' => 'visual_effects', 'attribute' => 'card_transition'],
];
}
}

View File

@@ -0,0 +1,501 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para Related Posts
*
* @package ROITheme\Admin\RelatedPost\Infrastructure\Ui
*/
final class RelatedPostFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildLayoutGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-grid-3x3-gap me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Posts Relacionados';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Seccion de posts relacionados con grid de cards';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="related-post">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('relatedPostEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('relatedPostShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="relatedPostShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="relatedPostShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// Section Title
$sectionTitle = $this->renderer->getFieldValue($componentId, 'content', 'section_title', 'Descubre Mas Contenido');
$html .= ' <div class="mb-3">';
$html .= ' <label for="relatedPostSectionTitle" class="form-label small mb-1 fw-semibold">Titulo de seccion</label>';
$html .= ' <input type="text" id="relatedPostSectionTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionTitle) . '">';
$html .= ' </div>';
// Posts per page
$postsPerPage = $this->renderer->getFieldValue($componentId, 'content', 'posts_per_page', '12');
$html .= ' <div class="mb-3">';
$html .= ' <label for="relatedPostPerPage" class="form-label small mb-1 fw-semibold">Posts por pagina</label>';
$html .= ' <input type="number" id="relatedPostPerPage" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($postsPerPage) . '" min="1" max="50">';
$html .= ' </div>';
// Order by
$orderby = $this->renderer->getFieldValue($componentId, 'content', 'orderby', 'rand');
$html .= ' <div class="mb-3">';
$html .= ' <label for="relatedPostOrderby" class="form-label small mb-1 fw-semibold">Ordenar por</label>';
$html .= ' <select id="relatedPostOrderby" class="form-select form-select-sm">';
$html .= ' <option value="rand"' . ($orderby === 'rand' ? ' selected' : '') . '>Aleatorio</option>';
$html .= ' <option value="date"' . ($orderby === 'date' ? ' selected' : '') . '>Fecha</option>';
$html .= ' <option value="title"' . ($orderby === 'title' ? ' selected' : '') . '>Titulo</option>';
$html .= ' <option value="comment_count"' . ($orderby === 'comment_count' ? ' selected' : '') . '>Comentarios</option>';
$html .= ' <option value="menu_order"' . ($orderby === 'menu_order' ? ' selected' : '') . '>Orden de menu</option>';
$html .= ' </select>';
$html .= ' </div>';
// Order direction
$order = $this->renderer->getFieldValue($componentId, 'content', 'order', 'DESC');
$html .= ' <div class="mb-3">';
$html .= ' <label for="relatedPostOrder" class="form-label small mb-1 fw-semibold">Direccion</label>';
$html .= ' <select id="relatedPostOrder" class="form-select form-select-sm">';
$html .= ' <option value="DESC"' . ($order === 'DESC' ? ' selected' : '') . '>Descendente</option>';
$html .= ' <option value="ASC"' . ($order === 'ASC' ? ' selected' : '') . '>Ascendente</option>';
$html .= ' </select>';
$html .= ' </div>';
// Show pagination
$showPagination = $this->renderer->getFieldValue($componentId, 'content', 'show_pagination', true);
$html .= $this->buildSwitch('relatedPostShowPagination', 'Mostrar paginacion', 'bi-three-dots', $showPagination);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildLayoutGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-grid me-2" style="color: #FF8600;"></i>';
$html .= ' Disposicion';
$html .= ' </h5>';
// Columns desktop
$colsDesktop = $this->renderer->getFieldValue($componentId, 'layout', 'columns_desktop', '3');
$html .= ' <div class="mb-3">';
$html .= ' <label for="relatedPostColsDesktop" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas escritorio';
$html .= ' </label>';
$html .= ' <select id="relatedPostColsDesktop" class="form-select form-select-sm">';
$html .= ' <option value="2"' . ($colsDesktop === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' <option value="3"' . ($colsDesktop === '3' ? ' selected' : '') . '>3 columnas</option>';
$html .= ' <option value="4"' . ($colsDesktop === '4' ? ' selected' : '') . '>4 columnas</option>';
$html .= ' </select>';
$html .= ' </div>';
// Columns tablet
$colsTablet = $this->renderer->getFieldValue($componentId, 'layout', 'columns_tablet', '2');
$html .= ' <div class="mb-3">';
$html .= ' <label for="relatedPostColsTablet" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-tablet me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas tablet';
$html .= ' </label>';
$html .= ' <select id="relatedPostColsTablet" class="form-select form-select-sm">';
$html .= ' <option value="1"' . ($colsTablet === '1' ? ' selected' : '') . '>1 columna</option>';
$html .= ' <option value="2"' . ($colsTablet === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' <option value="3"' . ($colsTablet === '3' ? ' selected' : '') . '>3 columnas</option>';
$html .= ' </select>';
$html .= ' </div>';
// Columns mobile
$colsMobile = $this->renderer->getFieldValue($componentId, 'layout', 'columns_mobile', '1');
$html .= ' <div class="mb-0">';
$html .= ' <label for="relatedPostColsMobile" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas movil';
$html .= ' </label>';
$html .= ' <select id="relatedPostColsMobile" class="form-select form-select-sm">';
$html .= ' <option value="1"' . ($colsMobile === '1' ? ' selected' : '') . '>1 columna</option>';
$html .= ' <option value="2"' . ($colsMobile === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$sectionTitleSize = $this->renderer->getFieldValue($componentId, 'typography', 'section_title_size', '1.75rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostSectionTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo seccion</label>';
$html .= ' <input type="text" id="relatedPostSectionTitleSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionTitleSize) . '">';
$html .= ' </div>';
$sectionTitleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'section_title_weight', '500');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostSectionTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo seccion</label>';
$html .= ' <input type="text" id="relatedPostSectionTitleWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionTitleWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$cardTitleSize = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostCardTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo card</label>';
$html .= ' <input type="text" id="relatedPostCardTitleSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTitleSize) . '">';
$html .= ' </div>';
$cardTitleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_weight', '500');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostCardTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo card</label>';
$html .= ' <input type="text" id="relatedPostCardTitleWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTitleWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Seccion
$html .= ' <p class="small fw-semibold mb-2">Seccion</p>';
$html .= ' <div class="row g-2 mb-3">';
$sectionTitleColor = $this->renderer->getFieldValue($componentId, 'colors', 'section_title_color', '#212529');
$html .= $this->buildColorPicker('relatedPostSectionTitleColor', 'Titulo seccion', $sectionTitleColor);
$html .= ' </div>';
// Cards
$html .= ' <p class="small fw-semibold mb-2">Cards</p>';
$html .= ' <div class="row g-2 mb-3">';
$cardBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_bg_color', '#ffffff');
$html .= $this->buildColorPicker('relatedPostCardBgColor', 'Fondo card', $cardBgColor);
$cardTitleColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_title_color', '#212529');
$html .= $this->buildColorPicker('relatedPostCardTitleColor', 'Titulo card', $cardTitleColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardHoverBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_bg_color', '#f8f9fa');
$html .= $this->buildColorPicker('relatedPostCardHoverBgColor', 'Fondo hover', $cardHoverBgColor);
$html .= ' </div>';
// Paginacion
$html .= ' <p class="small fw-semibold mb-2">Paginacion</p>';
$html .= ' <div class="row g-2 mb-3">';
$paginationBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_bg_color', '#ffffff');
$html .= $this->buildColorPicker('relatedPostPaginationBgColor', 'Fondo', $paginationBgColor);
$paginationTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_text_color', '#0d6efd');
$html .= $this->buildColorPicker('relatedPostPaginationTextColor', 'Texto', $paginationTextColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$paginationActiveBg = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_bg', '#0d6efd');
$html .= $this->buildColorPicker('relatedPostPaginationActiveBg', 'Activo fondo', $paginationActiveBg);
$paginationActiveText = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_text', '#ffffff');
$html .= $this->buildColorPicker('relatedPostPaginationActiveText', 'Activo texto', $paginationActiveText);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostSectionMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="relatedPostSectionMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginTop) . '">';
$html .= ' </div>';
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostSectionMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="relatedPostSectionMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="relatedPostTitleMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
$html .= ' </div>';
$gridGap = $this->renderer->getFieldValue($componentId, 'spacing', 'grid_gap', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostGridGap" class="form-label small mb-1 fw-semibold">Espacio cards</label>';
$html .= ' <input type="text" id="relatedPostGridGap" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($gridGap) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostCardPadding" class="form-label small mb-1 fw-semibold">Padding card</label>';
$html .= ' <input type="text" id="relatedPostCardPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardPadding) . '">';
$html .= ' </div>';
$paginationMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'pagination_margin_top', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostPaginationMarginTop" class="form-label small mb-1 fw-semibold">Margen paginacion</label>';
$html .= ' <input type="text" id="relatedPostPaginationMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($paginationMarginTop) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
$cardBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_border_radius', '0.375rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostCardBorderRadius" class="form-label small mb-1 fw-semibold">Radio borde card</label>';
$html .= ' <input type="text" id="relatedPostCardBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardBorderRadius) . '">';
$html .= ' </div>';
$cardTransition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_transition', '0.3s ease');
$html .= ' <div class="col-6">';
$html .= ' <label for="relatedPostCardTransition" class="form-label small mb-1 fw-semibold">Transicion</label>';
$html .= ' <input type="text" id="relatedPostCardTransition" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTransition) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="mb-3">';
$cardShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_shadow', '0 .125rem .25rem rgba(0,0,0,.075)');
$html .= ' <label for="relatedPostCardShadow" class="form-label small mb-1 fw-semibold">Sombra card</label>';
$html .= ' <input type="text" id="relatedPostCardShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardShadow) . '">';
$html .= ' </div>';
$html .= ' <div class="mb-0">';
$cardHoverShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_hover_shadow', '0 .5rem 1rem rgba(0,0,0,.15)');
$html .= ' <label for="relatedPostCardHoverShadow" class="form-label small mb-1 fw-semibold">Sombra hover</label>';
$html .= ' <input type="text" id="relatedPostCardHoverShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardHoverShadow) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Domain\Contracts;
/**
* Contrato para mapeo de campos de formulario a atributos de BD
*
* RESPONSABILIDAD:
* - Definir el mapeo de field IDs a grupos/atributos
* - Cada modulo implementa su propio mapper
*
* PRINCIPIOS:
* - ISP: Interfaz pequena (2 metodos)
* - DIP: Capas superiores dependen de esta abstraccion
*/
interface FieldMapperInterface
{
/**
* Retorna el nombre del componente que mapea
*
* @return string Nombre en kebab-case (ej: 'cta-box-sidebar')
*/
public function getComponentName(): string;
/**
* Retorna el mapeo de field IDs a grupo/atributo
*
* @return array<string, array{group: string, attribute: string}>
*
* Ejemplo:
* [
* 'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],
* 'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
* ]
*/
public function getFieldMapping(): array;
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Api\Wordpress;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
use ROITheme\Admin\Shared\Infrastructure\FieldMapping\FieldMapperRegistry;
/**
* Handler para peticiones AJAX del panel de administracion
*
* RESPONSABILIDAD:
* - Manejar HTTP (request/response)
* - Delegar mapeo a FieldMapperRegistry
* - NO contiene logica de mapeo
*
* PRINCIPIOS:
* - SRP: Solo maneja HTTP
* - OCP: Nuevos componentes no requieren modificar esta clase
* - DIP: Depende de abstracciones (FieldMapperRegistry)
*/
final class AdminAjaxHandler
{
public function __construct(
private readonly ?SaveComponentSettingsUseCase $saveComponentSettingsUseCase = null,
private readonly ?FieldMapperRegistry $fieldMapperRegistry = null
) {}
public function register(): void
{
add_action('wp_ajax_roi_save_component_settings', [$this, 'saveComponentSettings']);
add_action('wp_ajax_roi_reset_component_defaults', [$this, 'resetComponentDefaults']);
}
public function saveComponentSettings(): void
{
check_ajax_referer('roi_admin_dashboard', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'No tienes permisos para realizar esta accion.']);
}
$component = sanitize_text_field($_POST['component'] ?? '');
$settings = json_decode(stripslashes($_POST['settings'] ?? '{}'), true);
if (empty($component) || empty($settings)) {
wp_send_json_error(['message' => 'Datos incompletos.']);
}
// Obtener mapper del modulo correspondiente
if ($this->fieldMapperRegistry === null || !$this->fieldMapperRegistry->hasMapper($component)) {
wp_send_json_error([
'message' => "No existe mapper para el componente: {$component}"
]);
}
$mapper = $this->fieldMapperRegistry->getMapper($component);
$fieldMapping = $mapper->getFieldMapping();
// Mapear settings usando el mapper del modulo
$mappedSettings = $this->mapSettings($settings, $fieldMapping);
// Guardar usando Use Case
if ($this->saveComponentSettingsUseCase !== null) {
$updated = $this->saveComponentSettingsUseCase->execute($component, $mappedSettings);
wp_send_json_success([
'message' => sprintf('Se guardaron %d campos correctamente.', $updated)
]);
} else {
wp_send_json_error(['message' => 'Error: Use Case no disponible.']);
}
}
/**
* Mapea settings de field IDs a grupos/atributos
*/
private function mapSettings(array $settings, array $fieldMapping): array
{
$mappedSettings = [];
foreach ($settings as $fieldId => $value) {
if (!isset($fieldMapping[$fieldId])) {
continue;
}
$mapping = $fieldMapping[$fieldId];
$groupName = $mapping['group'];
$attributeName = $mapping['attribute'];
if (!isset($mappedSettings[$groupName])) {
$mappedSettings[$groupName] = [];
}
$mappedSettings[$groupName][$attributeName] = $value;
}
return $mappedSettings;
}
public function resetComponentDefaults(): void
{
// Verificar nonce
check_ajax_referer('roi_admin_dashboard', 'nonce');
// Verificar permisos
if (!current_user_can('manage_options')) {
wp_send_json_error([
'message' => 'No tienes permisos para realizar esta accion.'
]);
}
// Obtener componente
$component = sanitize_text_field($_POST['component'] ?? '');
if (empty($component)) {
wp_send_json_error([
'message' => 'Componente no especificado.'
]);
}
// Ruta al schema JSON
$schemaPath = get_template_directory() . '/Schemas/' . $component . '.json';
if (!file_exists($schemaPath)) {
wp_send_json_error([
'message' => 'Schema del componente no encontrado.'
]);
}
// Usar repositorio para restaurar valores
if ($this->saveComponentSettingsUseCase !== null) {
global $wpdb;
$repository = new \ROITheme\Shared\Infrastructure\Persistence\Wordpress\WordPressComponentSettingsRepository($wpdb);
$updated = $repository->resetToDefaults($component, $schemaPath);
wp_send_json_success([
'message' => sprintf('Se restauraron %d campos a sus valores por defecto.', $updated)
]);
} else {
wp_send_json_error([
'message' => 'Error: Repositorio no disponible.'
]);
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Provider para auto-registro de Field Mappers
*
* RESPONSABILIDAD:
* - Descubrir automaticamente FieldMappers en cada modulo
* - Registrarlos en el FieldMapperRegistry
*
* BENEFICIO:
* - Agregar nuevo componente = crear FieldMapper (sin tocar functions.php)
* - Eliminar componente = borrar carpeta (limpieza automatica)
*/
final class FieldMapperProvider
{
private const MODULES = [
'TopNotificationBar',
'Navbar',
'CtaLetsTalk',
'Hero',
'FeaturedImage',
'TableOfContents',
'CtaBoxSidebar',
'SocialShare',
'CtaPost',
'RelatedPost',
'ContactForm',
'Footer',
'ThemeSettings',
];
public function __construct(
private readonly FieldMapperRegistry $registry
) {}
/**
* Registra todos los FieldMappers disponibles
*/
public function registerAll(): void
{
foreach (self::MODULES as $module) {
$this->registerIfExists($module);
}
}
/**
* Registra un mapper si existe la clase
*/
private function registerIfExists(string $module): void
{
$className = sprintf(
'ROITheme\\Admin\\%s\\Infrastructure\\FieldMapping\\%sFieldMapper',
$module,
$module
);
if (class_exists($className)) {
$mapper = new $className();
if ($mapper instanceof FieldMapperInterface) {
$this->registry->register($mapper);
}
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Registro central de Field Mappers
*
* RESPONSABILIDAD:
* - Registrar mappers de cada modulo
* - Resolver mapper por nombre de componente
*
* PRINCIPIOS:
* - OCP: Nuevos mappers se registran sin modificar esta clase
* - SRP: Solo gestiona el registro, no contiene mapeos
*/
final class FieldMapperRegistry
{
/** @var array<string, FieldMapperInterface> */
private array $mappers = [];
/**
* Registra un mapper
*/
public function register(FieldMapperInterface $mapper): void
{
$this->mappers[$mapper->getComponentName()] = $mapper;
}
/**
* Obtiene un mapper por nombre de componente
*
* @throws \InvalidArgumentException Si no existe mapper para el componente
*/
public function getMapper(string $componentName): FieldMapperInterface
{
if (!isset($this->mappers[$componentName])) {
throw new \InvalidArgumentException(
"No field mapper registered for component: {$componentName}"
);
}
return $this->mappers[$componentName];
}
/**
* Verifica si existe mapper para un componente
*/
public function hasMapper(string $componentName): bool
{
return isset($this->mappers[$componentName]);
}
/**
* Obtiene todos los mappers registrados
*
* @return array<string, FieldMapperInterface>
*/
public function getAllMappers(): array
{
return $this->mappers;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Social Share
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class SocialShareFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'social-share';
}
public function getFieldMapping(): array
{
return [
// Visibility
'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'socialShareShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],
'socialShareLabelText' => ['group' => 'content', 'attribute' => 'label_text'],
// Networks
'socialShareFacebook' => ['group' => 'networks', 'attribute' => 'show_facebook'],
'socialShareFacebookUrl' => ['group' => 'networks', 'attribute' => 'facebook_url'],
'socialShareInstagram' => ['group' => 'networks', 'attribute' => 'show_instagram'],
'socialShareInstagramUrl' => ['group' => 'networks', 'attribute' => 'instagram_url'],
'socialShareLinkedin' => ['group' => 'networks', 'attribute' => 'show_linkedin'],
'socialShareLinkedinUrl' => ['group' => 'networks', 'attribute' => 'linkedin_url'],
'socialShareWhatsapp' => ['group' => 'networks', 'attribute' => 'show_whatsapp'],
'socialShareWhatsappNumber' => ['group' => 'networks', 'attribute' => 'whatsapp_number'],
'socialShareTwitter' => ['group' => 'networks', 'attribute' => 'show_twitter'],
'socialShareTwitterUrl' => ['group' => 'networks', 'attribute' => 'twitter_url'],
'socialShareEmail' => ['group' => 'networks', 'attribute' => 'show_email'],
'socialShareEmailAddress' => ['group' => 'networks', 'attribute' => 'email_address'],
// Colors
'socialShareLabelColor' => ['group' => 'colors', 'attribute' => 'label_color'],
'socialShareBorderTopColor' => ['group' => 'colors', 'attribute' => 'border_top_color'],
'socialShareButtonBg' => ['group' => 'colors', 'attribute' => 'button_background'],
'socialShareFacebookColor' => ['group' => 'colors', 'attribute' => 'facebook_color'],
'socialShareInstagramColor' => ['group' => 'colors', 'attribute' => 'instagram_color'],
'socialShareLinkedinColor' => ['group' => 'colors', 'attribute' => 'linkedin_color'],
'socialShareWhatsappColor' => ['group' => 'colors', 'attribute' => 'whatsapp_color'],
'socialShareTwitterColor' => ['group' => 'colors', 'attribute' => 'twitter_color'],
'socialShareEmailColor' => ['group' => 'colors', 'attribute' => 'email_color'],
// Typography
'socialShareLabelFontSize' => ['group' => 'typography', 'attribute' => 'label_font_size'],
'socialShareIconFontSize' => ['group' => 'typography', 'attribute' => 'icon_font_size'],
// Spacing
'socialShareMarginTop' => ['group' => 'spacing', 'attribute' => 'container_margin_top'],
'socialShareMarginBottom' => ['group' => 'spacing', 'attribute' => 'container_margin_bottom'],
'socialSharePaddingTop' => ['group' => 'spacing', 'attribute' => 'container_padding_top'],
'socialSharePaddingBottom' => ['group' => 'spacing', 'attribute' => 'container_padding_bottom'],
'socialShareLabelMarginBottom' => ['group' => 'spacing', 'attribute' => 'label_margin_bottom'],
'socialShareButtonsGap' => ['group' => 'spacing', 'attribute' => 'buttons_gap'],
'socialShareButtonPadding' => ['group' => 'spacing', 'attribute' => 'button_padding'],
// Visual Effects
'socialShareBorderTopWidth' => ['group' => 'visual_effects', 'attribute' => 'border_top_width'],
'socialShareButtonBorderWidth' => ['group' => 'visual_effects', 'attribute' => 'button_border_width'],
'socialShareButtonBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'button_border_radius'],
'socialShareTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
'socialShareHoverBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'hover_box_shadow'],
];
}
}

View File

@@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para Social Share
*
* Responsabilidad:
* - Generar HTML del formulario de configuracion
* - Usar Design System (Bootstrap 5)
* - Cargar valores desde BD via AdminDashboardRenderer
*
* @package ROITheme\Admin\SocialShare\Infrastructure\Ui
*/
final class SocialShareFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildNetworksGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-share me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Compartir en Redes';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Botones para compartir contenido en redes sociales';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="social-share">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
// is_enabled
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('socialShareEnabled', 'Activar componente', 'bi-power', $enabled);
// show_on_desktop
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('socialShareShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
// show_on_mobile
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="socialShareShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="socialShareShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// show_label
$showLabel = $this->renderer->getFieldValue($componentId, 'content', 'show_label', true);
$html .= $this->buildSwitch('socialShareShowLabel', 'Mostrar etiqueta', 'bi-tag', $showLabel);
// label_text
$labelText = $this->renderer->getFieldValue($componentId, 'content', 'label_text', 'Compartir:');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="socialShareLabelText" class="form-label small mb-1 fw-semibold">Texto etiqueta</label>';
$html .= ' <input type="text" id="socialShareLabelText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($labelText) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildNetworksGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-globe me-2" style="color: #FF8600;"></i>';
$html .= ' Redes Sociales';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">Configura las redes sociales y sus URLs</p>';
// Facebook
$showFacebook = $this->renderer->getFieldValue($componentId, 'networks', 'show_facebook', true);
$facebookUrl = $this->renderer->getFieldValue($componentId, 'networks', 'facebook_url', '');
$html .= $this->buildNetworkField('socialShareFacebook', 'socialShareFacebookUrl', 'Facebook', 'bi-facebook', $showFacebook, $facebookUrl, 'https://facebook.com/tu-pagina');
// Instagram
$showInstagram = $this->renderer->getFieldValue($componentId, 'networks', 'show_instagram', true);
$instagramUrl = $this->renderer->getFieldValue($componentId, 'networks', 'instagram_url', '');
$html .= $this->buildNetworkField('socialShareInstagram', 'socialShareInstagramUrl', 'Instagram', 'bi-instagram', $showInstagram, $instagramUrl, 'https://instagram.com/tu-perfil');
// LinkedIn
$showLinkedin = $this->renderer->getFieldValue($componentId, 'networks', 'show_linkedin', true);
$linkedinUrl = $this->renderer->getFieldValue($componentId, 'networks', 'linkedin_url', '');
$html .= $this->buildNetworkField('socialShareLinkedin', 'socialShareLinkedinUrl', 'LinkedIn', 'bi-linkedin', $showLinkedin, $linkedinUrl, 'https://linkedin.com/in/tu-perfil');
// WhatsApp
$showWhatsapp = $this->renderer->getFieldValue($componentId, 'networks', 'show_whatsapp', true);
$whatsappNumber = $this->renderer->getFieldValue($componentId, 'networks', 'whatsapp_number', '');
$html .= $this->buildNetworkField('socialShareWhatsapp', 'socialShareWhatsappNumber', 'WhatsApp', 'bi-whatsapp', $showWhatsapp, $whatsappNumber, '521234567890');
// X (Twitter)
$showTwitter = $this->renderer->getFieldValue($componentId, 'networks', 'show_twitter', true);
$twitterUrl = $this->renderer->getFieldValue($componentId, 'networks', 'twitter_url', '');
$html .= $this->buildNetworkField('socialShareTwitter', 'socialShareTwitterUrl', 'X (Twitter)', 'bi-twitter-x', $showTwitter, $twitterUrl, 'https://x.com/tu-perfil');
// Email
$showEmail = $this->renderer->getFieldValue($componentId, 'networks', 'show_email', true);
$emailAddress = $this->renderer->getFieldValue($componentId, 'networks', 'email_address', '');
$html .= $this->buildNetworkField('socialShareEmail', 'socialShareEmailAddress', 'Email', 'bi-envelope', $showEmail, $emailAddress, 'contacto@tudominio.com');
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildNetworkField(string $switchId, string $urlId, string $label, string $icon, mixed $checked, string $urlValue, string $placeholder): string
{
// Normalizar valor booleano desde BD
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-3 p-2 rounded" style="background-color: #f8f9fa;">';
// Switch
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($switchId),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small fw-semibold" for="%s">',
esc_attr($switchId)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
// URL Input
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm mt-2" value="%s" placeholder="%s">',
esc_attr($urlId),
esc_attr($urlValue),
esc_attr($placeholder)
);
$html .= ' </div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Colores generales
$html .= ' <p class="small fw-semibold mb-2">General</p>';
$html .= ' <div class="row g-2 mb-3">';
$labelColor = $this->renderer->getFieldValue($componentId, 'colors', 'label_color', '#6c757d');
$html .= $this->buildColorPicker('socialShareLabelColor', 'Etiqueta', $labelColor);
$borderTopColor = $this->renderer->getFieldValue($componentId, 'colors', 'border_top_color', '#dee2e6');
$html .= $this->buildColorPicker('socialShareBorderTopColor', 'Borde superior', $borderTopColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$buttonBackground = $this->renderer->getFieldValue($componentId, 'colors', 'button_background', '#ffffff');
$html .= $this->buildColorPicker('socialShareButtonBg', 'Fondo botones', $buttonBackground);
$html .= ' </div>';
// Colores por red social
$html .= ' <p class="small fw-semibold mb-2">Redes Sociales</p>';
$html .= ' <div class="row g-2 mb-3">';
$facebookColor = $this->renderer->getFieldValue($componentId, 'colors', 'facebook_color', '#0d6efd');
$html .= $this->buildColorPicker('socialShareFacebookColor', 'Facebook', $facebookColor);
$instagramColor = $this->renderer->getFieldValue($componentId, 'colors', 'instagram_color', '#dc3545');
$html .= $this->buildColorPicker('socialShareInstagramColor', 'Instagram', $instagramColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$linkedinColor = $this->renderer->getFieldValue($componentId, 'colors', 'linkedin_color', '#0dcaf0');
$html .= $this->buildColorPicker('socialShareLinkedinColor', 'LinkedIn', $linkedinColor);
$whatsappColor = $this->renderer->getFieldValue($componentId, 'colors', 'whatsapp_color', '#198754');
$html .= $this->buildColorPicker('socialShareWhatsappColor', 'WhatsApp', $whatsappColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$twitterColor = $this->renderer->getFieldValue($componentId, 'colors', 'twitter_color', '#212529');
$html .= $this->buildColorPicker('socialShareTwitterColor', 'X (Twitter)', $twitterColor);
$emailColor = $this->renderer->getFieldValue($componentId, 'colors', 'email_color', '#6c757d');
$html .= $this->buildColorPicker('socialShareEmailColor', 'Email', $emailColor);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-0">';
// label_font_size
$labelFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'label_font_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareLabelFontSize" class="form-label small mb-1 fw-semibold">Tamano etiqueta</label>';
$html .= ' <input type="text" id="socialShareLabelFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($labelFontSize) . '">';
$html .= ' </div>';
// icon_font_size
$iconFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'icon_font_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareIconFontSize" class="form-label small mb-1 fw-semibold">Tamano iconos</label>';
$html .= ' <input type="text" id="socialShareIconFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($iconFontSize) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// container_margin_top
$containerMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'container_margin_top', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="socialShareMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($containerMarginTop) . '">';
$html .= ' </div>';
// container_margin_bottom
$containerMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'container_margin_bottom', '3rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="socialShareMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($containerMarginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// container_padding_top
$containerPaddingTop = $this->renderer->getFieldValue($componentId, 'spacing', 'container_padding_top', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialSharePaddingTop" class="form-label small mb-1 fw-semibold">Padding superior</label>';
$html .= ' <input type="text" id="socialSharePaddingTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($containerPaddingTop) . '">';
$html .= ' </div>';
// container_padding_bottom
$containerPaddingBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'container_padding_bottom', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialSharePaddingBottom" class="form-label small mb-1 fw-semibold">Padding inferior</label>';
$html .= ' <input type="text" id="socialSharePaddingBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($containerPaddingBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// label_margin_bottom
$labelMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'label_margin_bottom', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareLabelMarginBottom" class="form-label small mb-1 fw-semibold">Margen etiqueta</label>';
$html .= ' <input type="text" id="socialShareLabelMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($labelMarginBottom) . '">';
$html .= ' </div>';
// buttons_gap
$buttonsGap = $this->renderer->getFieldValue($componentId, 'spacing', 'buttons_gap', '0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareButtonsGap" class="form-label small mb-1 fw-semibold">Espacio botones</label>';
$html .= ' <input type="text" id="socialShareButtonsGap" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonsGap) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// button_padding
$buttonPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'button_padding', '0.25rem 0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareButtonPadding" class="form-label small mb-1 fw-semibold">Padding botones</label>';
$html .= ' <input type="text" id="socialShareButtonPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonPadding) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// border_top_width
$borderTopWidth = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_top_width', '1px');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareBorderTopWidth" class="form-label small mb-1 fw-semibold">Grosor borde sup.</label>';
$html .= ' <input type="text" id="socialShareBorderTopWidth" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderTopWidth) . '">';
$html .= ' </div>';
// button_border_width
$buttonBorderWidth = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_border_width', '2px');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareButtonBorderWidth" class="form-label small mb-1 fw-semibold">Grosor borde btn</label>';
$html .= ' <input type="text" id="socialShareButtonBorderWidth" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonBorderWidth) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// button_border_radius
$buttonBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'button_border_radius', '0.375rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareButtonBorderRadius" class="form-label small mb-1 fw-semibold">Radio botones</label>';
$html .= ' <input type="text" id="socialShareButtonBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($buttonBorderRadius) . '">';
$html .= ' </div>';
// transition_duration
$transitionDuration = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="col-6">';
$html .= ' <label for="socialShareTransitionDuration" class="form-label small mb-1 fw-semibold">Duracion transicion</label>';
$html .= ' <input type="text" id="socialShareTransitionDuration" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transitionDuration) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// hover_box_shadow
$hoverBoxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'hover_box_shadow', '0 4px 12px rgba(0, 0, 0, 0.15)');
$html .= ' <div class="col-12">';
$html .= ' <label for="socialShareHoverBoxShadow" class="form-label small mb-1 fw-semibold">Sombra hover</label>';
$html .= ' <input type="text" id="socialShareHoverBoxShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($hoverBoxShadow) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
{
// Normalizar valor booleano desde BD
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Table of Contents
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class TableOfContentsFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'table-of-contents';
}
public function getFieldMapping(): array
{
return [
// Visibility
'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'tocShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'tocTitle' => ['group' => 'content', 'attribute' => 'title'],
'tocAutoGenerate' => ['group' => 'content', 'attribute' => 'auto_generate'],
'tocHeadingLevels' => ['group' => 'content', 'attribute' => 'heading_levels'],
'tocSmoothScroll' => ['group' => 'content', 'attribute' => 'smooth_scroll'],
// Typography
'tocTitleFontSize' => ['group' => 'typography', 'attribute' => 'title_font_size'],
'tocTitleFontWeight' => ['group' => 'typography', 'attribute' => 'title_font_weight'],
'tocLinkFontSize' => ['group' => 'typography', 'attribute' => 'link_font_size'],
'tocLinkLineHeight' => ['group' => 'typography', 'attribute' => 'link_line_height'],
'tocLevelThreeFontSize' => ['group' => 'typography', 'attribute' => 'level_three_font_size'],
'tocLevelFourFontSize' => ['group' => 'typography', 'attribute' => 'level_four_font_size'],
// Colors
'tocBackgroundColor' => ['group' => 'colors', 'attribute' => 'background_color'],
'tocBorderColor' => ['group' => 'colors', 'attribute' => 'border_color'],
'tocTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'tocTitleBorderColor' => ['group' => 'colors', 'attribute' => 'title_border_color'],
'tocLinkColor' => ['group' => 'colors', 'attribute' => 'link_color'],
'tocLinkHoverColor' => ['group' => 'colors', 'attribute' => 'link_hover_color'],
'tocLinkHoverBackground' => ['group' => 'colors', 'attribute' => 'link_hover_background'],
'tocActiveBorderColor' => ['group' => 'colors', 'attribute' => 'active_border_color'],
'tocActiveBackgroundColor' => ['group' => 'colors', 'attribute' => 'active_background_color'],
'tocActiveTextColor' => ['group' => 'colors', 'attribute' => 'active_text_color'],
'tocScrollbarTrackColor' => ['group' => 'colors', 'attribute' => 'scrollbar_track_color'],
'tocScrollbarThumbColor' => ['group' => 'colors', 'attribute' => 'scrollbar_thumb_color'],
// Spacing
'tocContainerPadding' => ['group' => 'spacing', 'attribute' => 'container_padding'],
'tocMarginBottom' => ['group' => 'spacing', 'attribute' => 'margin_bottom'],
'tocTitlePaddingBottom' => ['group' => 'spacing', 'attribute' => 'title_padding_bottom'],
'tocTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'tocItemMarginBottom' => ['group' => 'spacing', 'attribute' => 'item_margin_bottom'],
'tocLinkPadding' => ['group' => 'spacing', 'attribute' => 'link_padding'],
'tocLevelThreePaddingLeft' => ['group' => 'spacing', 'attribute' => 'level_three_padding_left'],
'tocLevelFourPaddingLeft' => ['group' => 'spacing', 'attribute' => 'level_four_padding_left'],
'tocScrollbarWidth' => ['group' => 'spacing', 'attribute' => 'scrollbar_width'],
// Visual Effects
'tocBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'border_radius'],
'tocBoxShadow' => ['group' => 'visual_effects', 'attribute' => 'box_shadow'],
'tocBorderWidth' => ['group' => 'visual_effects', 'attribute' => 'border_width'],
'tocLinkBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'link_border_radius'],
'tocActiveBorderLeftWidth' => ['group' => 'visual_effects', 'attribute' => 'active_border_left_width'],
'tocTransitionDuration' => ['group' => 'visual_effects', 'attribute' => 'transition_duration'],
'tocScrollbarBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'scrollbar_border_radius'],
// Behavior
'tocIsSticky' => ['group' => 'behavior', 'attribute' => 'is_sticky'],
'tocScrollOffset' => ['group' => 'behavior', 'attribute' => 'scroll_offset'],
'tocMaxHeight' => ['group' => 'behavior', 'attribute' => 'max_height'],
];
}
}

View File

@@ -0,0 +1,588 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para la Tabla de Contenido
*
* Responsabilidad:
* - Generar HTML del formulario de configuracion
* - Usar Design System (Bootstrap 5)
* - Cargar valores desde BD via AdminDashboardRenderer
*
* @package ROITheme\Admin\TableOfContents\Infrastructure\Ui
*/
final class TableOfContentsFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildBehaviorGroup($componentId);
$html .= $this->buildEffectsGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-list-nested me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Tabla de Contenido';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Navegacion automatica con ScrollSpy';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="table-of-contents">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Visibilidad';
$html .= ' </h5>';
// is_enabled
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= $this->buildSwitch('tocEnabled', 'Activar tabla de contenido', 'bi-power', $enabled);
// show_on_desktop
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('tocShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
// show_on_mobile
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="tocShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="tocShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// title
$title = $this->renderer->getFieldValue($componentId, 'content', 'title', 'Tabla de Contenido');
$html .= ' <div class="mb-3">';
$html .= ' <label for="tocTitle" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-type me-1" style="color: #FF8600;"></i>';
$html .= ' Titulo';
$html .= ' </label>';
$html .= ' <input type="text" id="tocTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($title) . '" placeholder="Tabla de Contenido">';
$html .= ' </div>';
// auto_generate
$autoGenerate = $this->renderer->getFieldValue($componentId, 'content', 'auto_generate', true);
$html .= $this->buildSwitch('tocAutoGenerate', 'Generar automaticamente', 'bi-magic', $autoGenerate);
$html .= ' <small class="text-muted d-block mb-3">Genera TOC desde los encabezados del contenido</small>';
// heading_levels
$headingLevels = $this->renderer->getFieldValue($componentId, 'content', 'heading_levels', 'h2,h3');
$html .= ' <div class="mb-3">';
$html .= ' <label for="tocHeadingLevels" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-list-ol me-1" style="color: #FF8600;"></i>';
$html .= ' Niveles de encabezados';
$html .= ' </label>';
$html .= ' <input type="text" id="tocHeadingLevels" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($headingLevels) . '" placeholder="h2,h3">';
$html .= ' <small class="text-muted">Separados por coma: h2,h3,h4</small>';
$html .= ' </div>';
// smooth_scroll
$smoothScroll = $this->renderer->getFieldValue($componentId, 'content', 'smooth_scroll', true);
$html .= $this->buildSwitch('tocSmoothScroll', 'Scroll suave', 'bi-arrow-down-circle', $smoothScroll);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildBehaviorGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-gear me-2" style="color: #FF8600;"></i>';
$html .= ' Comportamiento';
$html .= ' </h5>';
// is_sticky
$isSticky = $this->renderer->getFieldValue($componentId, 'behavior', 'is_sticky', true);
$html .= $this->buildSwitch('tocIsSticky', 'Sticky (fijo al scroll)', 'bi-pin', $isSticky);
// scroll_offset
$scrollOffset = $this->renderer->getFieldValue($componentId, 'behavior', 'scroll_offset', '100');
$html .= ' <div class="mb-3">';
$html .= ' <label for="tocScrollOffset" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-vertical me-1" style="color: #FF8600;"></i>';
$html .= ' Offset de scroll (px)';
$html .= ' </label>';
$html .= ' <input type="text" id="tocScrollOffset" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($scrollOffset) . '" placeholder="100">';
$html .= ' </div>';
// max_height
$maxHeight = $this->renderer->getFieldValue($componentId, 'behavior', 'max_height', 'calc(100vh - 71px - 10px - 250px - 15px - 15px)');
$html .= ' <div class="mb-0">';
$html .= ' <label for="tocMaxHeight" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>';
$html .= ' Altura maxima';
$html .= ' </label>';
$html .= ' <input type="text" id="tocMaxHeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($maxHeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografia';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// title_font_size
$titleFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocTitleFontSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="tocTitleFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontSize) . '" placeholder="1rem">';
$html .= ' </div>';
// title_font_weight
$titleFontWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_font_weight', '600');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocTitleFontWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="tocTitleFontWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleFontWeight) . '" placeholder="600">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// link_font_size
$linkFontSize = $this->renderer->getFieldValue($componentId, 'typography', 'link_font_size', '0.9rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLinkFontSize" class="form-label small mb-1 fw-semibold">Tamano enlaces</label>';
$html .= ' <input type="text" id="tocLinkFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($linkFontSize) . '" placeholder="0.9rem">';
$html .= ' </div>';
// link_line_height
$linkLineHeight = $this->renderer->getFieldValue($componentId, 'typography', 'link_line_height', '1.3');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLinkLineHeight" class="form-label small mb-1 fw-semibold">Altura linea</label>';
$html .= ' <input type="text" id="tocLinkLineHeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($linkLineHeight) . '" placeholder="1.3">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// level_three_font_size
$level3FontSize = $this->renderer->getFieldValue($componentId, 'typography', 'level_three_font_size', '0.85rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLevelThreeFontSize" class="form-label small mb-1 fw-semibold">Tamano H3</label>';
$html .= ' <input type="text" id="tocLevelThreeFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($level3FontSize) . '" placeholder="0.85rem">';
$html .= ' </div>';
// level_four_font_size
$level4FontSize = $this->renderer->getFieldValue($componentId, 'typography', 'level_four_font_size', '0.8rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLevelFourFontSize" class="form-label small mb-1 fw-semibold">Tamano H4</label>';
$html .= ' <input type="text" id="tocLevelFourFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($level4FontSize) . '" placeholder="0.8rem">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Colores principales
$html .= ' <p class="small fw-semibold mb-2">Contenedor</p>';
$html .= ' <div class="row g-2 mb-3">';
$bgColor = $this->renderer->getFieldValue($componentId, 'colors', 'background_color', '#ffffff');
$html .= $this->buildColorPicker('tocBackgroundColor', 'Fondo', $bgColor);
$borderColor = $this->renderer->getFieldValue($componentId, 'colors', 'border_color', '#E6E9ED');
$html .= $this->buildColorPicker('tocBorderColor', 'Borde', $borderColor);
$html .= ' </div>';
// Colores del titulo
$html .= ' <p class="small fw-semibold mb-2">Titulo</p>';
$html .= ' <div class="row g-2 mb-3">';
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#0E2337');
$html .= $this->buildColorPicker('tocTitleColor', 'Color', $titleColor);
$titleBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_border_color', '#E6E9ED');
$html .= $this->buildColorPicker('tocTitleBorderColor', 'Borde', $titleBorderColor);
$html .= ' </div>';
// Colores de enlaces
$html .= ' <p class="small fw-semibold mb-2">Enlaces</p>';
$html .= ' <div class="row g-2 mb-3">';
$linkColor = $this->renderer->getFieldValue($componentId, 'colors', 'link_color', '#6B7280');
$html .= $this->buildColorPicker('tocLinkColor', 'Normal', $linkColor);
$linkHoverColor = $this->renderer->getFieldValue($componentId, 'colors', 'link_hover_color', '#0E2337');
$html .= $this->buildColorPicker('tocLinkHoverColor', 'Hover', $linkHoverColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$linkHoverBg = $this->renderer->getFieldValue($componentId, 'colors', 'link_hover_background', '#F9FAFB');
$html .= $this->buildColorPicker('tocLinkHoverBackground', 'Fondo hover', $linkHoverBg);
$activeBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'active_border_color', '#0E2337');
$html .= $this->buildColorPicker('tocActiveBorderColor', 'Borde activo', $activeBorderColor);
$html .= ' </div>';
// Colores de activo
$html .= ' <p class="small fw-semibold mb-2">Estado Activo</p>';
$html .= ' <div class="row g-2 mb-3">';
$activeBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'active_background_color', '#F9FAFB');
$html .= $this->buildColorPicker('tocActiveBackgroundColor', 'Fondo', $activeBgColor);
$activeTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'active_text_color', '#0E2337');
$html .= $this->buildColorPicker('tocActiveTextColor', 'Texto', $activeTextColor);
$html .= ' </div>';
// Colores de scrollbar
$html .= ' <p class="small fw-semibold mb-2">Scrollbar</p>';
$html .= ' <div class="row g-2 mb-0">';
$scrollbarTrack = $this->renderer->getFieldValue($componentId, 'colors', 'scrollbar_track_color', '#F9FAFB');
$html .= $this->buildColorPicker('tocScrollbarTrackColor', 'Pista', $scrollbarTrack);
$scrollbarThumb = $this->renderer->getFieldValue($componentId, 'colors', 'scrollbar_thumb_color', '#6B7280');
$html .= $this->buildColorPicker('tocScrollbarThumbColor', 'Thumb', $scrollbarThumb);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
$html .= ' Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// container_padding
$containerPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'container_padding', '12px 16px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocContainerPadding" class="form-label small mb-1 fw-semibold">Padding contenedor</label>';
$html .= ' <input type="text" id="tocContainerPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($containerPadding) . '">';
$html .= ' </div>';
// margin_bottom
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_bottom', '13px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="tocMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// title_padding_bottom
$titlePaddingBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_padding_bottom', '8px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocTitlePaddingBottom" class="form-label small mb-1 fw-semibold">Padding titulo</label>';
$html .= ' <input type="text" id="tocTitlePaddingBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titlePaddingBottom) . '">';
$html .= ' </div>';
// title_margin_bottom
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '0.75rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="tocTitleMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// item_margin_bottom
$itemMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'item_margin_bottom', '0.15rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocItemMarginBottom" class="form-label small mb-1 fw-semibold">Margen items</label>';
$html .= ' <input type="text" id="tocItemMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($itemMarginBottom) . '">';
$html .= ' </div>';
// link_padding
$linkPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'link_padding', '0.3rem 0.85rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLinkPadding" class="form-label small mb-1 fw-semibold">Padding enlaces</label>';
$html .= ' <input type="text" id="tocLinkPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($linkPadding) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// level_three_padding_left
$level3PaddingLeft = $this->renderer->getFieldValue($componentId, 'spacing', 'level_three_padding_left', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLevelThreePaddingLeft" class="form-label small mb-1 fw-semibold">Padding H3</label>';
$html .= ' <input type="text" id="tocLevelThreePaddingLeft" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($level3PaddingLeft) . '">';
$html .= ' </div>';
// level_four_padding_left
$level4PaddingLeft = $this->renderer->getFieldValue($componentId, 'spacing', 'level_four_padding_left', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLevelFourPaddingLeft" class="form-label small mb-1 fw-semibold">Padding H4</label>';
$html .= ' <input type="text" id="tocLevelFourPaddingLeft" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($level4PaddingLeft) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildEffectsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
$html .= ' Efectos Visuales';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-3">';
// border_radius
$borderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_radius', '8px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocBorderRadius" class="form-label small mb-1 fw-semibold">Radio borde</label>';
$html .= ' <input type="text" id="tocBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderRadius) . '">';
$html .= ' </div>';
// border_width
$borderWidth = $this->renderer->getFieldValue($componentId, 'visual_effects', 'border_width', '1px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocBorderWidth" class="form-label small mb-1 fw-semibold">Grosor borde</label>';
$html .= ' <input type="text" id="tocBorderWidth" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($borderWidth) . '">';
$html .= ' </div>';
$html .= ' </div>';
// box_shadow
$boxShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'box_shadow', '0 2px 8px rgba(0, 0, 0, 0.08)');
$html .= ' <div class="mb-3">';
$html .= ' <label for="tocBoxShadow" class="form-label small mb-1 fw-semibold">Sombra</label>';
$html .= ' <input type="text" id="tocBoxShadow" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($boxShadow) . '">';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
// link_border_radius
$linkBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'link_border_radius', '4px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocLinkBorderRadius" class="form-label small mb-1 fw-semibold">Radio enlaces</label>';
$html .= ' <input type="text" id="tocLinkBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($linkBorderRadius) . '">';
$html .= ' </div>';
// active_border_left_width
$activeBorderLeftWidth = $this->renderer->getFieldValue($componentId, 'visual_effects', 'active_border_left_width', '3px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocActiveBorderLeftWidth" class="form-label small mb-1 fw-semibold">Borde activo</label>';
$html .= ' <input type="text" id="tocActiveBorderLeftWidth" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($activeBorderLeftWidth) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
// transition_duration
$transitionDuration = $this->renderer->getFieldValue($componentId, 'visual_effects', 'transition_duration', '0.3s');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocTransitionDuration" class="form-label small mb-1 fw-semibold">Transicion</label>';
$html .= ' <input type="text" id="tocTransitionDuration" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($transitionDuration) . '">';
$html .= ' </div>';
// scrollbar_border_radius
$scrollbarBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'scrollbar_border_radius', '3px');
$html .= ' <div class="col-6">';
$html .= ' <label for="tocScrollbarBorderRadius" class="form-label small mb-1 fw-semibold">Radio scrollbar</label>';
$html .= ' <input type="text" id="tocScrollbarBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($scrollbarBorderRadius) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSwitch(string $id, string $label, string $icon, bool $checked): string
{
$html = ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $value): string
{
$html = ' <div class="col-6">';
$html .= sprintf(
' <label class="form-label small fw-semibold">%s</label>',
esc_html($label)
);
$html .= ' <div class="input-group input-group-sm">';
$html .= sprintf(
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
esc_attr($id),
esc_attr($value)
);
$html .= sprintf(
' <span class="input-group-text" id="%sValue">%s</span>',
esc_attr($id),
esc_html(strtoupper($value))
);
$html .= ' </div>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ThemeSettings\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Theme Settings
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*
* NOTA: Logo/branding se gestiona desde el componente navbar
*/
final class ThemeSettingsFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'theme-settings';
}
public function getFieldMapping(): array
{
return [
// Analytics
'themeSettingsGaTrackingId' => ['group' => 'analytics', 'attribute' => 'ga_tracking_id'],
'themeSettingsGaAnonymizeIp' => ['group' => 'analytics', 'attribute' => 'ga_anonymize_ip'],
// AdSense
'themeSettingsAdsensePublisherId' => ['group' => 'adsense', 'attribute' => 'adsense_publisher_id'],
'themeSettingsAdsenseAutoAds' => ['group' => 'adsense', 'attribute' => 'adsense_auto_ads'],
// Custom Code
'themeSettingsCustomCss' => ['group' => 'custom_code', 'attribute' => 'custom_css'],
'themeSettingsCustomJsHeader' => ['group' => 'custom_code', 'attribute' => 'custom_js_header'],
'themeSettingsCustomJsFooter' => ['group' => 'custom_code', 'attribute' => 'custom_js_footer'],
];
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ThemeSettings\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para Theme Settings
*
* RESPONSABILIDAD: Generar formulario de configuraciones globales del tema
* (analytics, adsense, codigo personalizado)
*
* NOTA: Logo/branding se gestiona desde el componente navbar
*
* @package ROITheme\Admin\ThemeSettings\Infrastructure\Ui
*/
final class ThemeSettingsFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">';
// Columna izquierda - Analytics + AdSense
$html .= '<div class="col-lg-6">';
$html .= $this->buildAnalyticsGroup($componentId);
$html .= $this->buildAdSenseGroup($componentId);
$html .= '</div>';
// Columna derecha - Custom Code
$html .= '<div class="col-lg-6">';
$html .= $this->buildCustomCodeGroup($componentId);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-gear me-2" style="color: #FF8600;"></i>';
$html .= ' Configuraciones Globales del Tema';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Analytics, AdSense y Codigo Personalizado';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="theme-settings">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildAnalyticsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-graph-up me-2" style="color: #FF8600;"></i>';
$html .= ' Analytics';
$html .= ' </h5>';
$gaTrackingId = $this->renderer->getFieldValue($componentId, 'analytics', 'ga_tracking_id', '');
$html .= $this->buildTextInput('themeSettingsGaTrackingId', 'Google Analytics ID', 'bi-bar-chart', $gaTrackingId);
$html .= ' <div class="form-text small mb-2">Formato: G-XXXXXXXXXX o UA-XXXXXXXX-X</div>';
$gaAnonymizeIp = $this->renderer->getFieldValue($componentId, 'analytics', 'ga_anonymize_ip', true);
$html .= $this->buildSwitch('themeSettingsGaAnonymizeIp', 'Anonimizar IP (GDPR)', 'bi-shield-check', $gaAnonymizeIp);
$html .= ' <div class="alert alert-warning small mb-0 mt-2">';
$html .= ' <i class="bi bi-exclamation-triangle me-1"></i>';
$html .= ' Recomendado activar para cumplir con GDPR/RGPD';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildAdSenseGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-badge-ad me-2" style="color: #FF8600;"></i>';
$html .= ' Google AdSense';
$html .= ' </h5>';
$publisherId = $this->renderer->getFieldValue($componentId, 'adsense', 'adsense_publisher_id', '');
$html .= $this->buildTextInput('themeSettingsAdsensePublisherId', 'Publisher ID', 'bi-key', $publisherId);
$html .= ' <div class="form-text small mb-2">Formato: ca-pub-1234567890123456</div>';
$autoAds = $this->renderer->getFieldValue($componentId, 'adsense', 'adsense_auto_ads', false);
$html .= $this->buildSwitch('themeSettingsAdsenseAutoAds', 'Activar Auto Ads', 'bi-magic', $autoAds);
$html .= ' <div class="alert alert-info small mb-0 mt-2">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' Auto Ads permite que Google coloque anuncios automaticamente en las mejores ubicaciones.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildCustomCodeGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-code-slash me-2" style="color: #FF8600;"></i>';
$html .= ' Codigo Personalizado';
$html .= ' </h5>';
$customCss = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_css', '');
$html .= $this->buildTextareaCode('themeSettingsCustomCss', 'CSS Personalizado', 'bi-filetype-css', $customCss, 'Se inyecta en wp_head. No incluir etiquetas &lt;style&gt;');
$customJsHeader = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_header', '');
$html .= $this->buildTextareaCode('themeSettingsCustomJsHeader', 'JavaScript en Header', 'bi-filetype-js', $customJsHeader, 'Se inyecta en wp_head. No incluir etiquetas &lt;script&gt;');
$customJsFooter = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_footer', '');
$html .= $this->buildTextareaCode('themeSettingsCustomJsFooter', 'JavaScript en Footer', 'bi-filetype-js', $customJsFooter, 'Se inyecta en wp_footer. No incluir etiquetas &lt;script&gt;');
$html .= ' <div class="alert alert-danger small mb-0 mt-2">';
$html .= ' <i class="bi bi-exclamation-octagon me-1"></i>';
$html .= ' <strong>Advertencia:</strong> El codigo personalizado puede afectar el rendimiento y seguridad del sitio.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
// Helper methods
private function buildSwitch(string $id, string $label, string $icon, $value): string
{
$checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
$html = ' <div class="form-check form-switch mb-2">';
$html .= ' <input class="form-check-input" type="checkbox" id="' . esc_attr($id) . '" ' . $checked . '>';
$html .= ' <label class="form-check-label small" for="' . esc_attr($id) . '">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
private function buildTextInput(string $id, string $label, string $icon, mixed $value): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <input type="text" class="form-control form-control-sm" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '">';
$html .= ' </div>';
return $html;
}
private function buildTextareaCode(string $id, string $label, string $icon, mixed $value, string $helpText = ''): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <textarea class="form-control form-control-sm font-monospace" id="' . esc_attr($id) . '" rows="4" style="font-size: 0.85em;">' . esc_textarea($value) . '</textarea>';
if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . $helpText . '</div>';
}
$html .= ' </div>';
return $html;
}
/**
* Normaliza un valor a string para inputs de formulario
*/
private function normalizeStringValue(mixed $value): string
{
if ($value === false) {
return '0';
}
if ($value === true) {
return '1';
}
return (string) $value;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Top Notification Bar
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class TopNotificationBarFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'top-notification-bar';
}
public function getFieldMapping(): array
{
return [
// Visibility
'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Content
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],
'topBarLabelText' => ['group' => 'content', 'attribute' => 'label_text'],
'topBarMessageText' => ['group' => 'content', 'attribute' => 'message_text'],
'topBarLinkText' => ['group' => 'content', 'attribute' => 'link_text'],
'topBarLinkUrl' => ['group' => 'content', 'attribute' => 'link_url'],
// Colors
'topBarBackgroundColor' => ['group' => 'colors', 'attribute' => 'background_color'],
'topBarTextColor' => ['group' => 'colors', 'attribute' => 'text_color'],
'topBarLabelColor' => ['group' => 'colors', 'attribute' => 'label_color'],
'topBarIconColor' => ['group' => 'colors', 'attribute' => 'icon_color'],
'topBarLinkColor' => ['group' => 'colors', 'attribute' => 'link_color'],
'topBarLinkHoverColor' => ['group' => 'colors', 'attribute' => 'link_hover_color'],
// Spacing
'topBarFontSize' => ['group' => 'spacing', 'attribute' => 'font_size'],
'topBarPadding' => ['group' => 'spacing', 'attribute' => 'padding'],
];
}
}

View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
final class TopNotificationBarFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
// Header
$html .= $this->buildHeader($componentId);
// Layout 2 columnas
$html .= '<div class="row g-3">';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= ' </div>';
$html .= ' <div class="col-lg-6">';
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildTypographyAndSpacingGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildHeader(string $componentId): string
{
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-megaphone-fill me-2" style="color: #FF8600;"></i>';
$html .= ' Configuración de TopBar';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Personaliza la barra de notificación superior del sitio';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="top-notification-bar">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Activación y Visibilidad';
$html .= ' </h5>';
// Switch: Enabled
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarEnabled" ';
$html .= checked($enabled, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarEnabled" style="color: #495057;">';
$html .= ' <i class="bi bi-power me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Activar TopBar</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Show on Mobile
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarShowOnMobile" ';
$html .= checked($showOnMobile, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarShowOnMobile" style="color: #495057;">';
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Show on Desktop
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarShowOnDesktop" ';
$html .= checked($showOnDesktop, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarShowOnDesktop" style="color: #495057;">';
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="topBarShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-chat-text me-2" style="color: #FF8600;"></i>';
$html .= ' Contenido';
$html .= ' </h5>';
// icon_class + label_text (row)
$html .= ' <div class="row g-2 mb-2">';
$html .= ' <div class="col-6">';
$html .= ' <label for="topBarIconClass" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-star-fill me-1" style="color: #FF8600;"></i>';
$html .= ' Clase del ícono';
$html .= ' </label>';
$iconClass = $this->renderer->getFieldValue($componentId, 'content', 'icon_class', 'bi-megaphone-fill');
$html .= ' <input type="text" id="topBarIconClass" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($iconClass) . '" placeholder="bi-...">';
$html .= ' </div>';
$html .= ' <div class="col-6">';
$html .= ' <label for="topBarLabelText" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-tag me-1" style="color: #FF8600;"></i>';
$html .= ' Etiqueta';
$html .= ' </label>';
$labelText = $this->renderer->getFieldValue($componentId, 'content', 'label_text', 'Nuevo:');
$html .= ' <input type="text" id="topBarLabelText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($labelText) . '" maxlength="30">';
$html .= ' </div>';
$html .= ' </div>';
// message_text (textarea)
$messageText = $this->renderer->getFieldValue($componentId, 'content', 'message_text',
'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.');
$html .= ' <div class="mb-2">';
$html .= ' <label for="topBarMessageText" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-chat-dots me-1" style="color: #FF8600;"></i>';
$html .= ' Mensaje';
$html .= ' </label>';
$html .= ' <textarea id="topBarMessageText" class="form-control form-control-sm" rows="3" maxlength="200">';
$html .= esc_textarea($messageText);
$html .= ' </textarea>';
$html .= ' <small class="text-muted">Máximo 200 caracteres</small>';
$html .= ' </div>';
// link_text + link_url (row)
$html .= ' <div class="row g-2 mb-0">';
$html .= ' <div class="col-6">';
$html .= ' <label for="topBarLinkText" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
$html .= ' Texto del enlace';
$html .= ' </label>';
$linkText = $this->renderer->getFieldValue($componentId, 'content', 'link_text', 'Ver Catálogo');
$html .= ' <input type="text" id="topBarLinkText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($linkText) . '" maxlength="50">';
$html .= ' </div>';
$html .= ' <div class="col-6">';
$html .= ' <label for="topBarLinkUrl" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-box-arrow-up-right me-1" style="color: #FF8600;"></i>';
$html .= ' URL';
$html .= ' </label>';
$linkUrl = $this->renderer->getFieldValue($componentId, 'content', 'link_url', '#');
$html .= ' <input type="url" id="topBarLinkUrl" class="form-control form-control-sm" ';
$html .= ' value="' . esc_url($linkUrl) . '" placeholder="https://...">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
$html .= ' Colores';
$html .= ' </h5>';
// Grid 2x3 de color pickers
$html .= ' <div class="row g-2 mb-2">';
// Background Color
$bgColor = $this->renderer->getFieldValue($componentId, 'colors', 'background_color', '#0E2337');
$html .= $this->buildColorPicker('topBarBackgroundColor', 'Color de fondo', 'paint-bucket', $bgColor);
// Text Color
$textColor = $this->renderer->getFieldValue($componentId, 'colors', 'text_color', '#FFFFFF');
$html .= $this->buildColorPicker('topBarTextColor', 'Color de texto', 'fonts', $textColor);
// Label Color
$labelColor = $this->renderer->getFieldValue($componentId, 'colors', 'label_color', '#FF8600');
$html .= $this->buildColorPicker('topBarLabelColor', 'Color etiqueta', 'tag-fill', $labelColor);
// Icon Color
$iconColor = $this->renderer->getFieldValue($componentId, 'colors', 'icon_color', '#FF8600');
$html .= $this->buildColorPicker('topBarIconColor', 'Color ícono', 'star', $iconColor);
$html .= ' </div>';
// Row 2 de color pickers
$html .= ' <div class="row g-2 mb-0">';
// Link Color
$linkColor = $this->renderer->getFieldValue($componentId, 'colors', 'link_color', '#FFFFFF');
$html .= $this->buildColorPicker('topBarLinkColor', 'Color enlace', 'link', $linkColor);
// Link Hover Color
$linkHoverColor = $this->renderer->getFieldValue($componentId, 'colors', 'link_hover_color', '#FF8600');
$html .= $this->buildColorPicker('topBarLinkHoverColor', 'Color enlace (hover)', 'hand-index', $linkHoverColor);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTypographyAndSpacingGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-arrows-fullscreen me-2" style="color: #FF8600;"></i>';
$html .= ' Tipografía y Espaciado';
$html .= ' </h5>';
$html .= ' <div class="row g-2 mb-0">';
// Font Size
$html .= ' <div class="col-6">';
$html .= ' <label for="topBarFontSize" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-type me-1" style="color: #FF8600;"></i>';
$html .= ' Tamaño de fuente';
$html .= ' </label>';
$fontSize = $this->renderer->getFieldValue($componentId, 'spacing', 'font_size', '0.9rem');
$html .= ' <input type="text" id="topBarFontSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fontSize) . '">';
$html .= ' <small class="text-muted">Ej: 0.9rem, 14px</small>';
$html .= ' </div>';
// Padding
$html .= ' <div class="col-6">';
$html .= ' <label for="topBarPadding" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-bounding-box me-1" style="color: #FF8600;"></i>';
$html .= ' Padding vertical';
$html .= ' </label>';
$padding = $this->renderer->getFieldValue($componentId, 'spacing', 'padding', '0.5rem 0');
$html .= ' <input type="text" id="topBarPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($padding) . '">';
$html .= ' <small class="text-muted">Ej: 0.5rem 0</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildColorPicker(string $id, string $label, string $icon, string $value): string
{
$html = ' <div class="col-6">';
$html .= ' <label for="' . $id . '" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
$html .= ' <i class="bi bi-' . $icon . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . $label;
$html .= ' </label>';
$html .= ' <input type="color" id="' . $id . '" class="form-control form-control-color w-100" ';
$html .= ' value="' . esc_attr($value) . '" title="' . esc_attr($label) . '">';
$html .= ' <small class="text-muted d-block mt-1" id="' . $id . 'Value">' . esc_html(strtoupper($value)) . '</small>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TopBar - Preview de Diseño</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background-color: #f0f0f1;
padding: 20px;
}
</style>
</head>
<body>
<!-- ============================================================
TAB: TOP NOTIFICATION BAR CONFIGURATION
============================================================ -->
<div class="tab-pane fade show active" id="topBarTab" role="tabpanel">
<!-- ========================================
PATRÓN 1: HEADER CON GRADIENTE
======================================== -->
<div class="rounded p-4 mb-4 shadow text-white" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h3 class="h4 mb-1 fw-bold">
<i class="bi bi-megaphone-fill me-2" style="color: #FF8600;"></i>
Configuración de TopBar
</h3>
<p class="mb-0 small" style="opacity: 0.85;">
Personaliza la barra de notificación superior del sitio
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-light" id="resetTopBarDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- ========================================
PATRÓN 2: LAYOUT 2 COLUMNAS
======================================== -->
<div class="row g-3">
<div class="col-lg-6">
<!-- ========================================
GRUPO 1: ACTIVACIÓN Y VISIBILIDAD (OBLIGATORIO)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>
Activación y Visibilidad
</h5>
<!-- ⚠️ PATRÓN 4: SWITCHES VERTICALES CON ICONOS (3 OBLIGATORIOS) -->
<!-- Switch 1: Enabled (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarEnabled" checked>
<label class="form-check-label small" for="topBarEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Activar TopBar</strong>
</label>
</div>
</div>
<!-- Switch 2: Show on Mobile (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarShowOnMobile" checked>
<label class="form-check-label small" for="topBarShowOnMobile" style="color: #495057;">
<i class="bi bi-phone me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>
</label>
</div>
</div>
<!-- Switch 3: Show on Desktop (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarShowOnDesktop" checked>
<label class="form-check-label small" for="topBarShowOnDesktop" style="color: #495057;">
<i class="bi bi-display me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>
</label>
</div>
</div>
<!-- Campo adicional del schema: show_on_pages (select) -->
<div class="mb-0 mt-3">
<label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>
Mostrar en
</label>
<select id="topBarShowOnPages" class="form-select form-select-sm">
<option value="all" selected>Todas las páginas</option>
<option value="home">Solo página de inicio</option>
<option value="posts">Solo posts individuales</option>
<option value="pages">Solo páginas</option>
</select>
</div>
</div>
</div>
<!-- ========================================
GRUPO 2: CONTENIDO
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-chat-text me-2" style="color: #FF8600;"></i>
Contenido
</h5>
<!-- icon_class + label_text (compactados) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="topBarIconClass" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-star-fill me-1" style="color: #FF8600;"></i>
Clase del ícono
</label>
<input type="text" id="topBarIconClass" class="form-control form-control-sm" value="bi-megaphone-fill" placeholder="bi-...">
</div>
<div class="col-6">
<label for="topBarLabelText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-tag me-1" style="color: #FF8600;"></i>
Etiqueta
</label>
<input type="text" id="topBarLabelText" class="form-control form-control-sm" value="Nuevo:" maxlength="30">
</div>
</div>
<!-- message_text (textarea full width) -->
<div class="mb-2">
<label for="topBarMessageText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-chat-dots me-1" style="color: #FF8600;"></i>
Mensaje
</label>
<textarea id="topBarMessageText" class="form-control form-control-sm" rows="3" maxlength="200">Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</textarea>
<small class="text-muted">Máximo 200 caracteres</small>
</div>
<!-- link_text + link_url (compactados) -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="topBarLinkText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>
Texto del enlace
</label>
<input type="text" id="topBarLinkText" class="form-control form-control-sm" value="Ver Catálogo" maxlength="50">
</div>
<div class="col-6">
<label for="topBarLinkUrl" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-box-arrow-up-right me-1" style="color: #FF8600;"></i>
URL
</label>
<input type="url" id="topBarLinkUrl" class="form-control form-control-sm" value="#" placeholder="https://...">
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<!-- ========================================
GRUPO 3: ESTILOS - COLORES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-palette me-2" style="color: #FF8600;"></i>
Estilos - Colores
</h5>
<!-- PATRÓN 5: COLOR PICKERS EN GRID 2X2 -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="topBarBackgroundColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Color de fondo
</label>
<input type="color" id="topBarBackgroundColor" class="form-control form-control-color w-100" value="#0E2337" title="Color de fondo">
<small class="text-muted d-block mt-1" id="topBarBackgroundColorValue">#0E2337</small>
</div>
<div class="col-6">
<label for="topBarTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Color de texto
</label>
<input type="color" id="topBarTextColor" class="form-control form-control-color w-100" value="#FFFFFF" title="Color de texto">
<small class="text-muted d-block mt-1" id="topBarTextColorValue">#FFFFFF</small>
</div>
<div class="col-6">
<label for="topBarLabelColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-tag-fill me-1" style="color: #FF8600;"></i>
Color etiqueta
</label>
<input type="color" id="topBarLabelColor" class="form-control form-control-color w-100" value="#FF8600" title="Color etiqueta">
<small class="text-muted d-block mt-1" id="topBarLabelColorValue">#FF8600</small>
</div>
<div class="col-6">
<label for="topBarIconColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-star me-1" style="color: #FF8600;"></i>
Color ícono
</label>
<input type="color" id="topBarIconColor" class="form-control form-control-color w-100" value="#FF8600" title="Color ícono">
<small class="text-muted d-block mt-1" id="topBarIconColorValue">#FF8600</small>
</div>
</div>
<div class="row g-2 mb-0">
<div class="col-6">
<label for="topBarLinkColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-link me-1" style="color: #FF8600;"></i>
Color enlace
</label>
<input type="color" id="topBarLinkColor" class="form-control form-control-color w-100" value="#FFFFFF" title="Color enlace">
<small class="text-muted d-block mt-1" id="topBarLinkColorValue">#FFFFFF</small>
</div>
<div class="col-6">
<label for="topBarLinkHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>
Color enlace (hover)
</label>
<input type="color" id="topBarLinkHoverColor" class="form-control form-control-color w-100" value="#FF8600" title="Color enlace hover">
<small class="text-muted d-block mt-1" id="topBarLinkHoverColorValue">#FF8600</small>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 4: ESTILOS - TAMAÑOS
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-arrows-fullscreen me-2" style="color: #FF8600;"></i>
Estilos - Tamaños
</h5>
<div class="row g-2 mb-0">
<div class="col-6">
<label for="topBarFontSize" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type me-1" style="color: #FF8600;"></i>
Tamaño de fuente
</label>
<input type="text" id="topBarFontSize" class="form-control form-control-sm" value="0.9rem">
<small class="text-muted">Ej: 0.9rem, 14px</small>
</div>
<div class="col-6">
<label for="topBarPadding" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-bounding-box me-1" style="color: #FF8600;"></i>
Padding vertical
</label>
<input type="text" id="topBarPadding" class="form-control form-control-sm" value="0.5rem 0">
<small class="text-muted">Ej: 0.5rem 0</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /tab-pane -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

0
Public/.gitkeep Normal file
View File

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ContactForm\Infrastructure\Api\Wordpress;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* ContactFormAjaxHandler - Procesa envios del formulario de contacto
*
* RESPONSABILIDAD: Recibir datos del formulario y enviarlos al webhook configurado
*
* SEGURIDAD:
* - Verifica nonce
* - Webhook URL NUNCA se expone al cliente
* - Webhook URL se obtiene de BD server-side
* - Rate limiting basico
* - Sanitizacion de inputs
*
* @package ROITheme\Public\ContactForm\Infrastructure\Api\WordPress
*/
final class ContactFormAjaxHandler
{
private const NONCE_ACTION = 'roi_contact_form_nonce';
private const COMPONENT_NAME = 'contact-form';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Registrar hooks AJAX
* Usa wp_ajax_nopriv para usuarios no logueados
*/
public function register(): void
{
add_action('wp_ajax_roi_contact_form_submit', [$this, 'handleSubmit']);
add_action('wp_ajax_nopriv_roi_contact_form_submit', [$this, 'handleSubmit']);
}
/**
* Procesar envio del formulario
*/
public function handleSubmit(): void
{
// 1. Verificar nonce
$nonce = sanitize_text_field($_POST['nonce'] ?? '');
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
wp_send_json_error([
'message' => __('Error de seguridad. Por favor recarga la pagina.', 'roi-theme')
], 403);
return;
}
// 2. Rate limiting basico (1 envio por IP cada 30 segundos)
if (!$this->checkRateLimit()) {
wp_send_json_error([
'message' => __('Por favor espera un momento antes de enviar otro mensaje.', 'roi-theme')
], 429);
return;
}
// 3. Sanitizar y validar inputs
$formData = $this->sanitizeFormData($_POST);
$validation = $this->validateFormData($formData);
if (!$validation['valid']) {
wp_send_json_error([
'message' => $validation['message'],
'errors' => $validation['errors']
], 422);
return;
}
// 4. Obtener configuracion del componente (incluye webhook URL)
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
wp_send_json_error([
'message' => __('Error de configuracion. Contacta al administrador.', 'roi-theme')
], 500);
return;
}
$integration = $settings['integration'] ?? [];
$webhookUrl = $integration['webhook_url'] ?? '';
$webhookMethod = $integration['webhook_method'] ?? 'POST';
$includePageUrl = $this->toBool($integration['include_page_url'] ?? true);
$includeTimestamp = $this->toBool($integration['include_timestamp'] ?? true);
if (empty($webhookUrl)) {
// Si no hay webhook configurado, simular exito para UX
// pero loguear warning para admin
error_log('ROI Theme Contact Form: No webhook URL configured');
wp_send_json_success([
'message' => $this->getSuccessMessage($settings)
]);
return;
}
// 5. Preparar payload para webhook
$payload = $this->preparePayload($formData, $includePageUrl, $includeTimestamp);
// 6. Enviar a webhook
$result = $this->sendToWebhook($webhookUrl, $webhookMethod, $payload);
if ($result['success']) {
wp_send_json_success([
'message' => $this->getSuccessMessage($settings)
]);
} else {
error_log('ROI Theme Contact Form webhook error: ' . $result['error']);
wp_send_json_error([
'message' => $this->getErrorMessage($settings)
], 500);
}
}
/**
* Sanitizar datos del formulario
*/
private function sanitizeFormData(array $post): array
{
return [
'fullName' => sanitize_text_field($post['fullName'] ?? ''),
'company' => sanitize_text_field($post['company'] ?? ''),
'whatsapp' => sanitize_text_field($post['whatsapp'] ?? ''),
'email' => sanitize_email($post['email'] ?? ''),
'message' => sanitize_textarea_field($post['message'] ?? ''),
];
}
/**
* Validar datos del formulario
*/
private function validateFormData(array $data): array
{
$errors = [];
// Nombre requerido
if (empty($data['fullName'])) {
$errors['fullName'] = __('El nombre es obligatorio', 'roi-theme');
}
// WhatsApp requerido
if (empty($data['whatsapp'])) {
$errors['whatsapp'] = __('El WhatsApp es obligatorio', 'roi-theme');
}
// Email requerido y valido
if (empty($data['email'])) {
$errors['email'] = __('El email es obligatorio', 'roi-theme');
} elseif (!is_email($data['email'])) {
$errors['email'] = __('Por favor ingresa un email valido', 'roi-theme');
}
if (!empty($errors)) {
return [
'valid' => false,
'message' => __('Por favor corrige los errores del formulario', 'roi-theme'),
'errors' => $errors
];
}
return ['valid' => true, 'message' => '', 'errors' => []];
}
/**
* Preparar payload para webhook
*/
private function preparePayload(array $formData, bool $includePageUrl, bool $includeTimestamp): array
{
$payload = [
'fullName' => $formData['fullName'],
'company' => $formData['company'],
'whatsapp' => $formData['whatsapp'],
'email' => $formData['email'],
'message' => $formData['message'],
];
if ($includePageUrl) {
$payload['pageUrl'] = sanitize_url($_POST['pageUrl'] ?? '');
$payload['pageTitle'] = sanitize_text_field($_POST['pageTitle'] ?? '');
}
if ($includeTimestamp) {
$payload['timestamp'] = current_time('c');
$payload['timezone'] = wp_timezone_string();
}
// Metadata adicional util para el webhook
$payload['source'] = 'contact-form';
$payload['siteName'] = get_bloginfo('name');
$payload['siteUrl'] = home_url();
return $payload;
}
/**
* Enviar datos al webhook
*/
private function sendToWebhook(string $url, string $method, array $payload): array
{
$args = [
'method' => strtoupper($method),
'timeout' => 30,
'redirection' => 5,
'httpversion' => '1.1',
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
];
if ($method === 'POST') {
$args['body'] = wp_json_encode($payload);
} else {
$url = add_query_arg($payload, $url);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return [
'success' => false,
'error' => $response->get_error_message()
];
}
$statusCode = wp_remote_retrieve_response_code($response);
// Considerar 2xx como exito
if ($statusCode >= 200 && $statusCode < 300) {
return ['success' => true, 'error' => ''];
}
return [
'success' => false,
'error' => sprintf('HTTP %d: %s', $statusCode, wp_remote_retrieve_response_message($response))
];
}
/**
* Rate limiting basico por IP
*/
private function checkRateLimit(): bool
{
$ip = $this->getClientIP();
$transientKey = 'roi_contact_form_' . md5($ip);
$lastSubmit = get_transient($transientKey);
if ($lastSubmit !== false) {
return false;
}
set_transient($transientKey, time(), 30);
return true;
}
/**
* Obtener IP del cliente
*/
private function getClientIP(): string
{
$ip = '';
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
return $ip;
}
/**
* Obtener mensaje de exito desde configuracion
*/
private function getSuccessMessage(array $data): string
{
$messages = $data['messages'] ?? [];
return $messages['success_message'] ?? __('¡Gracias por contactarnos! Te responderemos pronto.', 'roi-theme');
}
/**
* Obtener mensaje de error desde configuracion
*/
private function getErrorMessage(array $data): string
{
$messages = $data['messages'] ?? [];
return $messages['error_message'] ?? __('Hubo un error al enviar el mensaje. Por favor intenta de nuevo.', 'roi-theme');
}
/**
* Convertir valor a boolean
*/
private function toBool($value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}

View File

@@ -0,0 +1,777 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ContactForm\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* ContactFormRenderer - Renderiza formulario de contacto con webhook
*
* RESPONSABILIDAD: Generar HTML y CSS del formulario de contacto
*
* CARACTERISTICAS:
* - Formulario responsive Bootstrap 5
* - Envio a webhook configurable (no expuesto en frontend)
* - Info de contacto configurable
* - Mensajes de exito/error personalizables
*
* @package ROITheme\Public\ContactForm\Infrastructure\Ui
*/
final class ContactFormRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
$js = $this->buildJS($data);
return sprintf("<style>%s</style>\n%s\n<script>%s</script>", $css, $html, $js);
}
/**
* Renderiza el modal de contacto para el boton Let's Talk
* Usa la misma configuracion y webhook que el formulario de seccion
*/
public function renderModal(Component $component): string
{
$data = $component->getData();
$css = $this->generateModalCSS($data);
$html = $this->buildModalHTML($data);
$js = $this->buildModalJS($data);
return sprintf("<style>%s</style>\n%s\n<script>%s</script>", $css, $html, $js);
}
public function supports(string $componentType): bool
{
return $componentType === 'contact-form';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$cssRules = [];
// Section background
$sectionBgColor = $colors['section_bg_color'] ?? 'rgba(108, 117, 125, 0.25)';
$sectionPaddingY = $spacing['section_padding_y'] ?? '3rem';
$sectionMarginTop = $spacing['section_margin_top'] ?? '3rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section', [
'background-color' => $sectionBgColor,
'padding-top' => $sectionPaddingY,
'padding-bottom' => $sectionPaddingY,
'margin-top' => $sectionMarginTop,
]);
// Title
$titleColor = $colors['title_color'] ?? '#212529';
$titleMarginBottom = $spacing['title_margin_bottom'] ?? '0.75rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .contact-title', [
'color' => $titleColor,
'margin-bottom' => $titleMarginBottom,
]);
// Description
$descColor = $colors['description_color'] ?? '#212529';
$descMarginBottom = $spacing['description_margin_bottom'] ?? '1.5rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .contact-description', [
'color' => $descColor,
'margin-bottom' => $descMarginBottom,
]);
// Icons
$iconColor = $colors['icon_color'] ?? '#FF8600';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .contact-icon', [
'color' => $iconColor,
]);
// Info labels and values
$infoLabelColor = $colors['info_label_color'] ?? '#212529';
$infoValueColor = $colors['info_value_color'] ?? '#6c757d';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .info-label', [
'color' => $infoLabelColor,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .info-value', [
'color' => $infoValueColor,
]);
// Form inputs
$inputBorderColor = $colors['input_border_color'] ?? '#dee2e6';
$inputFocusBorder = $colors['input_focus_border'] ?? '#FF8600';
$inputBorderRadius = $effects['input_border_radius'] ?? '6px';
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .form-control', [
'border-color' => $inputBorderColor,
'border-radius' => $inputBorderRadius,
'transition' => "all {$transitionDuration} ease",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .form-control:focus', [
'border-color' => $inputFocusBorder,
'box-shadow' => "0 0 0 0.2rem rgba(255, 134, 0, 0.25)",
'outline' => 'none',
]);
// Submit button
$buttonBgColor = $colors['button_bg_color'] ?? '#FF8600';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBg = $colors['button_hover_bg'] ?? '#e67a00';
$buttonBorderRadius = $effects['button_border_radius'] ?? '6px';
$buttonPadding = $effects['button_padding'] ?? '0.75rem 2rem';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .btn-contact-submit', [
'background-color' => $buttonBgColor,
'color' => $buttonTextColor,
'font-weight' => '600',
'padding' => $buttonPadding,
'border' => 'none',
'border-radius' => $buttonBorderRadius,
'transition' => "all {$transitionDuration} ease",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .btn-contact-submit:hover', [
'background-color' => $buttonHoverBg,
'color' => $buttonTextColor,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .btn-contact-submit:disabled', [
'opacity' => '0.7',
'cursor' => 'not-allowed',
]);
// Success/Error messages
$successBgColor = $colors['success_bg_color'] ?? '#d1e7dd';
$successTextColor = $colors['success_text_color'] ?? '#0f5132';
$errorBgColor = $colors['error_bg_color'] ?? '#f8d7da';
$errorTextColor = $colors['error_text_color'] ?? '#842029';
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .alert-success', [
'background-color' => $successBgColor,
'color' => $successTextColor,
'border-color' => $successBgColor,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-contact-form-section .alert-danger', [
'background-color' => $errorBgColor,
'color' => $errorTextColor,
'border-color' => $errorBgColor,
]);
return implode("\n", $cssRules);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$contactInfo = $data['contact_info'] ?? [];
$formLabels = $data['form_labels'] ?? [];
$effects = $data['visual_effects'] ?? [];
// Content
$sectionTitle = $content['section_title'] ?? '¿Tienes alguna pregunta?';
$sectionDesc = $content['section_description'] ?? 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.';
$submitText = $content['submit_button_text'] ?? 'Enviar Mensaje';
$submitIcon = $content['submit_button_icon'] ?? 'bi-send-fill';
// Contact info
$showContactInfo = $contactInfo['show_contact_info'] ?? true;
$showContactInfo = $showContactInfo === true || $showContactInfo === '1' || $showContactInfo === 1;
// Form labels/placeholders
$fullnamePlaceholder = $formLabels['fullname_placeholder'] ?? 'Nombre completo *';
$companyPlaceholder = $formLabels['company_placeholder'] ?? 'Empresa';
$whatsappPlaceholder = $formLabels['whatsapp_placeholder'] ?? 'WhatsApp *';
$emailPlaceholder = $formLabels['email_placeholder'] ?? 'Correo electrónico *';
$messagePlaceholder = $formLabels['message_placeholder'] ?? '¿En qué podemos ayudarte?';
$textareaRows = $effects['textarea_rows'] ?? '4';
// Container class
$containerClass = 'roi-contact-form-section';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
// Nonce for AJAX security
$nonce = wp_create_nonce('roi_contact_form_nonce');
$html = sprintf('<section class="%s">', esc_attr($containerClass));
$html .= '<div class="container">';
$html .= '<div class="row justify-content-center">';
$html .= '<div class="col-lg-10">';
$html .= '<div class="row">';
// Left column - Contact info
$html .= '<div class="col-lg-5 mb-4 mb-lg-0">';
$html .= sprintf('<h2 class="h3 contact-title">%s</h2>', esc_html($sectionTitle));
$html .= sprintf('<p class="contact-description">%s</p>', esc_html($sectionDesc));
if ($showContactInfo) {
$html .= $this->buildContactInfoHTML($contactInfo);
}
$html .= '</div>';
// Right column - Form
$html .= '<div class="col-lg-7">';
$html .= sprintf('<form id="roiContactForm" data-nonce="%s">', esc_attr($nonce));
$html .= '<div class="row g-3">';
// Full name field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="text" class="form-control" id="roiContactFullName" name="fullName" placeholder="%s" required>',
esc_attr($fullnamePlaceholder)
);
$html .= '</div>';
// Company field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="text" class="form-control" id="roiContactCompany" name="company" placeholder="%s">',
esc_attr($companyPlaceholder)
);
$html .= '</div>';
// WhatsApp field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="tel" class="form-control" id="roiContactWhatsapp" name="whatsapp" placeholder="%s" required>',
esc_attr($whatsappPlaceholder)
);
$html .= '</div>';
// Email field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="email" class="form-control" id="roiContactEmail" name="email" placeholder="%s" required>',
esc_attr($emailPlaceholder)
);
$html .= '</div>';
// Message field
$html .= '<div class="col-12">';
$html .= sprintf(
'<textarea class="form-control" id="roiContactMessage" name="message" rows="%s" placeholder="%s"></textarea>',
esc_attr($textareaRows),
esc_attr($messagePlaceholder)
);
$html .= '</div>';
// Submit button
$html .= '<div class="col-12">';
$html .= '<button type="submit" class="btn btn-contact-submit w-100">';
$html .= sprintf('<i class="%s me-2"></i>', esc_attr($submitIcon));
$html .= esc_html($submitText);
$html .= '</button>';
$html .= '</div>';
// Message container
$html .= '<div id="roiContactFormMessage" class="col-12 mt-2 alert" style="display: none;"></div>';
$html .= '</div>'; // .row g-3
$html .= '</form>';
$html .= '</div>'; // .col-lg-7
$html .= '</div>'; // .row
$html .= '</div>'; // .col-lg-10
$html .= '</div>'; // .row justify-content-center
$html .= '</div>'; // .container
$html .= '</section>';
return $html;
}
private function buildContactInfoHTML(array $contactInfo): string
{
$phoneLabel = $contactInfo['phone_label'] ?? 'Teléfono';
$phoneValue = $contactInfo['phone_value'] ?? '+52 55 1234 5678';
$emailLabel = $contactInfo['email_label'] ?? 'Email';
$emailValue = $contactInfo['email_value'] ?? 'contacto@apumexico.com';
$locationLabel = $contactInfo['location_label'] ?? 'Ubicación';
$locationValue = $contactInfo['location_value'] ?? 'Ciudad de México, México';
$html = '<div class="contact-info">';
// Phone
$html .= '<div class="d-flex align-items-start mb-3">';
$html .= '<i class="bi bi-telephone-fill me-3 fs-5 contact-icon"></i>';
$html .= '<div>';
$html .= sprintf('<h6 class="mb-1 info-label">%s</h6>', esc_html($phoneLabel));
$html .= sprintf('<p class="mb-0 info-value">%s</p>', esc_html($phoneValue));
$html .= '</div>';
$html .= '</div>';
// Email
$html .= '<div class="d-flex align-items-start mb-3">';
$html .= '<i class="bi bi-envelope-fill me-3 fs-5 contact-icon"></i>';
$html .= '<div>';
$html .= sprintf('<h6 class="mb-1 info-label">%s</h6>', esc_html($emailLabel));
$html .= sprintf('<p class="mb-0 info-value">%s</p>', esc_html($emailValue));
$html .= '</div>';
$html .= '</div>';
// Location
$html .= '<div class="d-flex align-items-start">';
$html .= '<i class="bi bi-geo-alt-fill me-3 fs-5 contact-icon"></i>';
$html .= '<div>';
$html .= sprintf('<h6 class="mb-1 info-label">%s</h6>', esc_html($locationLabel));
$html .= sprintf('<p class="mb-0 info-value">%s</p>', esc_html($locationValue));
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildJS(array $data): string
{
$messages = $data['messages'] ?? [];
$content = $data['content'] ?? [];
$successMessage = $messages['success_message'] ?? '¡Gracias por contactarnos! Te responderemos pronto.';
$errorMessage = $messages['error_message'] ?? 'Hubo un error al enviar el mensaje. Por favor intenta de nuevo.';
$sendingMessage = $messages['sending_message'] ?? 'Enviando...';
$submitText = $content['submit_button_text'] ?? 'Enviar Mensaje';
$submitIcon = $content['submit_button_icon'] ?? 'bi-send-fill';
// AJAX URL for WordPress
$ajaxUrl = admin_url('admin-ajax.php');
$js = <<<JS
(function() {
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('roiContactForm');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
const messageDiv = document.getElementById('roiContactFormMessage');
const originalBtnHtml = submitBtn.innerHTML;
const nonce = form.dataset.nonce;
// Disable button and show sending state
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
messageDiv.style.display = 'none';
// Collect form data
const formData = new FormData(form);
formData.append('action', 'roi_contact_form_submit');
formData.append('nonce', nonce);
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
try {
const response = await fetch('{$ajaxUrl}', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
messageDiv.className = 'col-12 mt-2 alert alert-success';
messageDiv.textContent = '{$successMessage}';
messageDiv.style.display = 'block';
form.reset();
} else {
messageDiv.className = 'col-12 mt-2 alert alert-danger';
messageDiv.textContent = result.data?.message || '{$errorMessage}';
messageDiv.style.display = 'block';
}
} catch (error) {
console.error('Contact form error:', error);
messageDiv.className = 'col-12 mt-2 alert alert-danger';
messageDiv.textContent = '{$errorMessage}';
messageDiv.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnHtml;
}
});
});
})();
JS;
return $js;
}
/**
* Generar CSS para el modal
*/
private function generateModalCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$effects = $data['visual_effects'] ?? [];
$cssRules = [];
// Modal header con gradiente del tema
$cssRules[] = $this->cssGenerator->generate('#contactModal .modal-header', [
'background' => 'linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%)',
'border-bottom' => 'none',
'padding' => '1.5rem',
]);
$cssRules[] = $this->cssGenerator->generate('#contactModal .modal-title', [
'color' => '#ffffff',
'font-weight' => '600',
]);
$cssRules[] = $this->cssGenerator->generate('#contactModal .btn-close', [
'filter' => 'brightness(0) invert(1)',
]);
// Modal body
$cssRules[] = $this->cssGenerator->generate('#contactModal .modal-body', [
'padding' => '2rem',
]);
// Form inputs
$inputBorderColor = $colors['input_border_color'] ?? '#dee2e6';
$inputFocusBorder = $colors['input_focus_border'] ?? '#FF8600';
$inputBorderRadius = $effects['input_border_radius'] ?? '6px';
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$cssRules[] = $this->cssGenerator->generate('#contactModal .form-control', [
'border-color' => $inputBorderColor,
'border-radius' => $inputBorderRadius,
'transition' => "all {$transitionDuration} ease",
]);
$cssRules[] = $this->cssGenerator->generate('#contactModal .form-control:focus', [
'border-color' => $inputFocusBorder,
'box-shadow' => '0 0 0 0.2rem rgba(255, 134, 0, 0.25)',
'outline' => 'none',
]);
// Submit button
$buttonBgColor = $colors['button_bg_color'] ?? '#FF8600';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBg = $colors['button_hover_bg'] ?? '#e67a00';
$buttonBorderRadius = $effects['button_border_radius'] ?? '6px';
$buttonPadding = $effects['button_padding'] ?? '0.75rem 2rem';
$cssRules[] = $this->cssGenerator->generate('#contactModal .btn-modal-submit', [
'background-color' => $buttonBgColor,
'color' => $buttonTextColor,
'font-weight' => '600',
'padding' => $buttonPadding,
'border' => 'none',
'border-radius' => $buttonBorderRadius,
'transition' => "all {$transitionDuration} ease",
]);
$cssRules[] = $this->cssGenerator->generate('#contactModal .btn-modal-submit:hover', [
'background-color' => $buttonHoverBg,
'color' => $buttonTextColor,
]);
$cssRules[] = $this->cssGenerator->generate('#contactModal .btn-modal-submit:disabled', [
'opacity' => '0.7',
'cursor' => 'not-allowed',
]);
// Success/Error messages
$successBgColor = $colors['success_bg_color'] ?? '#d1e7dd';
$successTextColor = $colors['success_text_color'] ?? '#0f5132';
$errorBgColor = $colors['error_bg_color'] ?? '#f8d7da';
$errorTextColor = $colors['error_text_color'] ?? '#842029';
$cssRules[] = $this->cssGenerator->generate('#contactModal .alert-success', [
'background-color' => $successBgColor,
'color' => $successTextColor,
'border-color' => $successBgColor,
]);
$cssRules[] = $this->cssGenerator->generate('#contactModal .alert-danger', [
'background-color' => $errorBgColor,
'color' => $errorTextColor,
'border-color' => $errorBgColor,
]);
return implode("\n", $cssRules);
}
/**
* Generar HTML del modal
*/
private function buildModalHTML(array $data): string
{
$content = $data['content'] ?? [];
$formLabels = $data['form_labels'] ?? [];
$effects = $data['visual_effects'] ?? [];
// Content
$sectionTitle = $content['section_title'] ?? '¿Tienes alguna pregunta?';
$submitText = $content['submit_button_text'] ?? 'Enviar Mensaje';
$submitIcon = $content['submit_button_icon'] ?? 'bi-send-fill';
// Form labels/placeholders
$fullnamePlaceholder = $formLabels['fullname_placeholder'] ?? 'Nombre completo *';
$companyPlaceholder = $formLabels['company_placeholder'] ?? 'Empresa';
$whatsappPlaceholder = $formLabels['whatsapp_placeholder'] ?? 'WhatsApp *';
$emailPlaceholder = $formLabels['email_placeholder'] ?? 'Correo electrónico *';
$messagePlaceholder = $formLabels['message_placeholder'] ?? '¿En qué podemos ayudarte?';
$textareaRows = $effects['textarea_rows'] ?? '4';
// Nonce for AJAX security
$nonce = wp_create_nonce('roi_contact_form_nonce');
$html = '<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">';
$html .= '<div class="modal-dialog modal-dialog-centered modal-lg">';
$html .= '<div class="modal-content">';
// Modal Header
$html .= '<div class="modal-header">';
$html .= '<h5 class="modal-title" id="contactModalLabel">';
$html .= '<i class="bi bi-chat-dots-fill me-2" style="color: #FF8600;"></i>';
$html .= esc_html($sectionTitle);
$html .= '</h5>';
$html .= '<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>';
$html .= '</div>';
// Modal Body
$html .= '<div class="modal-body">';
$html .= sprintf('<form id="roiContactModalForm" data-nonce="%s">', esc_attr($nonce));
$html .= '<div class="row g-3">';
// Full name field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="text" class="form-control" id="roiModalFullName" name="fullName" placeholder="%s" required>',
esc_attr($fullnamePlaceholder)
);
$html .= '</div>';
// Company field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="text" class="form-control" id="roiModalCompany" name="company" placeholder="%s">',
esc_attr($companyPlaceholder)
);
$html .= '</div>';
// WhatsApp field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="tel" class="form-control" id="roiModalWhatsapp" name="whatsapp" placeholder="%s" required>',
esc_attr($whatsappPlaceholder)
);
$html .= '</div>';
// Email field
$html .= '<div class="col-md-6">';
$html .= sprintf(
'<input type="email" class="form-control" id="roiModalEmail" name="email" placeholder="%s" required>',
esc_attr($emailPlaceholder)
);
$html .= '</div>';
// Message field
$html .= '<div class="col-12">';
$html .= sprintf(
'<textarea class="form-control" id="roiModalMessage" name="message" rows="%s" placeholder="%s"></textarea>',
esc_attr($textareaRows),
esc_attr($messagePlaceholder)
);
$html .= '</div>';
// Submit button
$html .= '<div class="col-12">';
$html .= '<button type="submit" class="btn btn-modal-submit w-100">';
$html .= sprintf('<i class="%s me-2"></i>', esc_attr($submitIcon));
$html .= esc_html($submitText);
$html .= '</button>';
$html .= '</div>';
// Message container
$html .= '<div id="roiContactModalMessage" class="col-12 mt-2 alert" style="display: none;"></div>';
$html .= '</div>'; // .row g-3
$html .= '</form>';
$html .= '</div>'; // .modal-body
$html .= '</div>'; // .modal-content
$html .= '</div>'; // .modal-dialog
$html .= '</div>'; // .modal
return $html;
}
/**
* Generar JS para el modal
*/
private function buildModalJS(array $data): string
{
$messages = $data['messages'] ?? [];
$content = $data['content'] ?? [];
$successMessage = $messages['success_message'] ?? '¡Gracias por contactarnos! Te responderemos pronto.';
$errorMessage = $messages['error_message'] ?? 'Hubo un error al enviar el mensaje. Por favor intenta de nuevo.';
$sendingMessage = $messages['sending_message'] ?? 'Enviando...';
// AJAX URL for WordPress
$ajaxUrl = admin_url('admin-ajax.php');
$js = <<<JS
(function() {
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('roiContactModalForm');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
const messageDiv = document.getElementById('roiContactModalMessage');
const originalBtnHtml = submitBtn.innerHTML;
const nonce = form.dataset.nonce;
// Disable button and show sending state
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
messageDiv.style.display = 'none';
// Collect form data
const formData = new FormData(form);
formData.append('action', 'roi_contact_form_submit');
formData.append('nonce', nonce);
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
try {
const response = await fetch('{$ajaxUrl}', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
messageDiv.className = 'col-12 mt-2 alert alert-success';
messageDiv.textContent = '{$successMessage}';
messageDiv.style.display = 'block';
form.reset();
// Cerrar modal despues de 2 segundos en exito
setTimeout(function() {
const modal = bootstrap.Modal.getInstance(document.getElementById('contactModal'));
if (modal) {
modal.hide();
}
messageDiv.style.display = 'none';
}, 2000);
} else {
messageDiv.className = 'col-12 mt-2 alert alert-danger';
messageDiv.textContent = result.data?.message || '{$errorMessage}';
messageDiv.style.display = 'block';
}
} catch (error) {
console.error('Contact modal form error:', error);
messageDiv.className = 'col-12 mt-2 alert alert-danger';
messageDiv.textContent = '{$errorMessage}';
messageDiv.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnHtml;
}
});
// Limpiar formulario cuando se cierra el modal
const contactModal = document.getElementById('contactModal');
if (contactModal) {
contactModal.addEventListener('hidden.bs.modal', function() {
form.reset();
const messageDiv = document.getElementById('roiContactModalMessage');
if (messageDiv) {
messageDiv.style.display = 'none';
}
});
}
});
})();
JS;
return $js;
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar
*
* RESPONSABILIDAD: Generar HTML y CSS del CTA Box Sidebar
*
* CARACTERISTICAS:
* - Titulo configurable
* - Descripcion configurable
* - Boton con icono y multiples acciones (modal, link, scroll)
* - Estilos 100% desde BD via CSSGenerator
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar CTA box)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\CtaBoxSidebar\Infrastructure\Ui
*/
final class CtaBoxSidebarRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
$script = $this->buildScript();
return sprintf("<style>%s</style>\n%s\n%s", $css, $html, $script);
}
public function supports(string $componentType): bool
{
return $componentType === 'cta-box-sidebar';
}
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$effects = $data['visual_effects'] ?? [];
$behavior = $data['behavior'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
// Container styles - Match template exactly (height: 250px, flexbox centering)
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar', [
'background' => $colors['background_color'] ?? '#FF8600',
'border-radius' => $effects['border_radius'] ?? '8px',
'padding' => $spacing['container_padding'] ?? '24px',
'text-align' => $behavior['text_align'] ?? 'center',
'box-shadow' => $effects['box_shadow'] ?? '0 4px 12px rgba(255, 133, 0, 0.2)',
'margin-top' => '0',
'margin-bottom' => '15px',
'height' => '250px',
'display' => 'flex',
'flex-direction' => 'column',
'justify-content' => 'center',
]);
// Title styles
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .cta-box-title', [
'color' => $colors['title_color'] ?? '#ffffff',
'font-weight' => $typography['title_font_weight'] ?? '700',
'font-size' => $typography['title_font_size'] ?? '1.25rem',
'margin-bottom' => $spacing['title_margin_bottom'] ?? '1rem',
'margin-top' => '0',
]);
// Description styles
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .cta-box-text', [
'color' => $colors['description_color'] ?? 'rgba(255, 255, 255, 0.95)',
'font-size' => $typography['description_font_size'] ?? '0.9rem',
'margin-bottom' => $spacing['description_margin_bottom'] ?? '1rem',
]);
// Button styles
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .btn-cta-box', [
'background-color' => $colors['button_background_color'] ?? '#ffffff',
'color' => $colors['button_text_color'] ?? '#FF8600',
'font-weight' => $typography['button_font_weight'] ?? '700',
'font-size' => $typography['button_font_size'] ?? '1rem',
'border' => 'none',
'padding' => $spacing['button_padding'] ?? '0.75rem 1.5rem',
'border-radius' => $effects['button_border_radius'] ?? '8px',
'transition' => "all {$transitionDuration} ease",
'cursor' => 'pointer',
'display' => 'inline-flex',
'align-items' => 'center',
'justify-content' => 'center',
'width' => '100%',
]);
// Button hover styles (template uses --color-navy-primary = #1e3a5f)
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .btn-cta-box:hover', [
'background-color' => $colors['button_hover_background'] ?? '#1e3a5f',
'color' => $colors['button_hover_text_color'] ?? '#ffffff',
]);
// Button icon spacing
$cssRules[] = $this->cssGenerator->generate('.cta-box-sidebar .btn-cta-box i', [
'margin-right' => $spacing['icon_margin_right'] ?? '0.5rem',
]);
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? false;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.cta-box-sidebar { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.cta-box-sidebar { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$title = $content['title'] ?? '¿Listo para potenciar tus proyectos?';
$description = $content['description'] ?? 'Accede a nuestra biblioteca completa de APUs y herramientas profesionales.';
$buttonText = $content['button_text'] ?? 'Solicitar Demo';
$buttonIcon = $content['button_icon'] ?? 'bi bi-calendar-check';
$buttonAction = $content['button_action'] ?? 'modal';
$buttonLink = $content['button_link'] ?? '#contactModal';
// Build button attributes based on action type
$buttonAttributes = $this->getButtonAttributes($buttonAction, $buttonLink);
$html = '<div class="cta-box-sidebar">';
// Title
$html .= sprintf(
'<h5 class="cta-box-title">%s</h5>',
esc_html($title)
);
// Description
$html .= sprintf(
'<p class="cta-box-text">%s</p>',
esc_html($description)
);
// Button/Link
$iconHtml = !empty($buttonIcon)
? sprintf('<i class="%s"></i>', esc_attr($buttonIcon))
: '';
// Use <a> for link action, <button> for modal/scroll
if ($buttonAction === 'link') {
$html .= sprintf(
'<a href="%s" class="btn btn-cta-box">%s%s</a>',
esc_url($buttonLink),
$iconHtml,
esc_html($buttonText)
);
} else {
$html .= sprintf(
'<button class="btn btn-cta-box" %s>%s%s</button>',
$buttonAttributes,
$iconHtml,
esc_html($buttonText)
);
}
$html .= '</div>';
return $html;
}
private function getButtonAttributes(string $action, string $link): string
{
switch ($action) {
case 'modal':
// Extract modal ID from link (e.g., #contactModal -> contactModal)
$modalId = ltrim($link, '#');
return sprintf(
'type="button" data-bs-toggle="modal" data-bs-target="#%s"',
esc_attr($modalId)
);
case 'link':
return sprintf(
'type="button" data-cta-action="link" data-cta-href="%s"',
esc_url($link)
);
case 'scroll':
$targetId = ltrim($link, '#');
return sprintf(
'type="button" data-cta-action="scroll" data-cta-target="%s"',
esc_attr($targetId)
);
default:
return 'type="button"';
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
private function buildScript(): string
{
return <<<JS
<script>
document.addEventListener('DOMContentLoaded', function() {
var ctaButtons = document.querySelectorAll('.btn-cta-box[data-cta-action]');
ctaButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var action = this.getAttribute('data-cta-action');
if (action === 'link') {
var href = this.getAttribute('data-cta-href');
if (href) window.location.href = href;
} else if (action === 'scroll') {
var target = this.getAttribute('data-cta-target');
var el = document.getElementById(target);
if (el) el.scrollIntoView({behavior: 'smooth'});
}
});
});
});
</script>
JS;
}
}

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* Class CtaLetsTalkRenderer
*
* Renderizador del componente CTA "Let's Talk" para el frontend.
*
* Responsabilidades:
* - Renderizar botón CTA "Let's Talk" en el navbar
* - Delegar generación de CSS a CSSGeneratorInterface
* - Validar visibilidad (is_enabled, show_on_pages, show_on_desktop, show_on_mobile)
* - Manejar visibilidad responsive con clases Bootstrap
* - Generar atributos para modal o URL personalizada
* - Sanitizar todos los outputs
*
* NO responsable de:
* - Generar string CSS (delega a CSSGeneratorService)
* - Persistir datos (ya están en Component)
* - Lógica de negocio (está en Domain)
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar este componente)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\CtaLetsTalk\Infrastructure\Ui
*/
final class CtaLetsTalkRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
/**
* {@inheritDoc}
*/
public function render(Component $component): string
{
$data = $component->getData();
// Validar visibilidad general
if (!$this->isEnabled($data)) {
return '';
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
// Generar CSS usando CSSGeneratorService
$css = $this->generateCSS($data);
// Generar HTML
$html = $this->buildHTML($data);
// Combinar todo
return sprintf(
"<style>%s</style>\n%s",
$css,
$html
);
}
/**
* {@inheritDoc}
*/
public function supports(string $componentType): bool
{
return $componentType === 'cta-lets-talk';
}
/**
* Verificar si el componente está habilitado
*
* @param array $data Datos del componente
* @return bool
*/
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
default => true,
};
}
/**
* Calcular clases de visibilidad responsive
*
* @param bool $desktop Mostrar en desktop
* @param bool $mobile Mostrar en mobile
* @return string|null Clases CSS o null si no debe mostrarse
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
/**
* Generar CSS usando CSSGeneratorService
*
* @param array $data Datos del componente
* @return string CSS generado
*/
private function generateCSS(array $data): string
{
$css = '';
// Estilos base del botón
$baseStyles = [
'background_color' => $data['colors']['background_color'] ?? '#FF8600',
'color' => $data['colors']['text_color'] ?? '#FFFFFF',
'font_size' => $data['typography']['font_size'] ?? '1rem',
'font_weight' => $data['typography']['font_weight'] ?? '600',
'text_transform' => $data['typography']['text_transform'] ?? 'none',
'padding' => sprintf(
'%s %s',
$data['spacing']['padding_top_bottom'] ?? '0.5rem',
$data['spacing']['padding_left_right'] ?? '1.5rem'
),
'border' => sprintf(
'%s solid %s',
$data['visual_effects']['border_width'] ?? '0',
$data['colors']['border_color'] ?? 'transparent'
),
'border_radius' => $data['visual_effects']['border_radius'] ?? '6px',
'box_shadow' => $data['visual_effects']['box_shadow'] ?? 'none',
'transition' => sprintf(
'all %s ease',
$data['visual_effects']['transition_duration'] ?? '0.3s'
),
'cursor' => 'pointer',
];
$css .= $this->cssGenerator->generate('.btn-lets-talk', $baseStyles);
// Estilos hover del botón
$hoverStyles = [
'background_color' => $data['colors']['background_hover_color'] ?? '#FF6B35',
'color' => $data['colors']['text_hover_color'] ?? '#FFFFFF',
];
$css .= "\n" . $this->cssGenerator->generate('.btn-lets-talk:hover', $hoverStyles);
// Estilos del ícono dentro del botón
$iconStyles = [
'color' => $data['colors']['text_color'] ?? '#FFFFFF',
'margin_right' => $data['spacing']['icon_spacing'] ?? '0.5rem',
];
$css .= "\n" . $this->cssGenerator->generate('.btn-lets-talk i', $iconStyles);
// Estilos responsive - ocultar en móvil si show_on_mobile = false
$showOnMobile = ($data['visibility']['show_on_mobile'] ?? false) === true;
if (!$showOnMobile) {
$responsiveStyles = [
'display' => 'none !important',
];
$css .= "\n@media (max-width: 991px) {\n";
$css .= $this->cssGenerator->generate('.btn-lets-talk', $responsiveStyles);
$css .= "\n}";
}
// Estilos responsive - ocultar en desktop si show_on_desktop = false
$showOnDesktop = ($data['visibility']['show_on_desktop'] ?? true) === true;
if (!$showOnDesktop) {
$responsiveStyles = [
'display' => 'none !important',
];
$css .= "\n@media (min-width: 992px) {\n";
$css .= $this->cssGenerator->generate('.btn-lets-talk', $responsiveStyles);
$css .= "\n}";
}
// Margen izquierdo para separar del menú (solo desktop)
$marginLeft = $data['spacing']['margin_left'] ?? '1rem';
if (!empty($marginLeft) && $marginLeft !== '0') {
$css .= "\n@media (min-width: 992px) {\n";
$css .= $this->cssGenerator->generate('.btn-lets-talk', ['margin_left' => $marginLeft]);
$css .= "\n}";
}
return $css;
}
/**
* Generar HTML del componente
*
* @param array $data Datos del componente
* @return string HTML generado
*/
private function buildHTML(array $data): string
{
$classes = $this->buildClasses($data);
$attributes = $this->buildAttributes($data);
$content = $this->buildContent($data);
$tag = $this->useModal($data) ? 'button' : 'a';
return sprintf(
'<%s class="%s"%s>%s</%s>',
$tag,
esc_attr($classes),
$attributes,
$content,
$tag
);
}
/**
* Construir clases CSS del componente
*
* @param array $data Datos del componente
* @return string Clases CSS
*/
private function buildClasses(array $data): string
{
$classes = ['btn', 'btn-lets-talk'];
// Agregar clase ms-lg-3 para margen en desktop (Bootstrap)
// Esto solo aplica en pantallas >= lg (992px)
$classes[] = 'ms-lg-3';
return implode(' ', $classes);
}
/**
* Determinar si debe usar modal o URL
*
* @param array $data Datos del componente
* @return bool
*/
private function useModal(array $data): bool
{
return ($data['behavior']['enable_modal'] ?? true) === true;
}
/**
* Construir atributos HTML del componente
*
* @param array $data Datos del componente
* @return string Atributos HTML
*/
private function buildAttributes(array $data): string
{
$attributes = [];
if ($this->useModal($data)) {
// Atributos para modal de Bootstrap
$attributes[] = 'type="button"';
$attributes[] = 'data-bs-toggle="modal"';
$modalTarget = $data['content']['modal_target'] ?? '#contactModal';
$attributes[] = sprintf('data-bs-target="%s"', esc_attr($modalTarget));
} else {
// Atributos para enlace
$customUrl = $data['behavior']['custom_url'] ?? '';
$attributes[] = sprintf('href="%s"', esc_url($customUrl ?: '#'));
if (($data['behavior']['open_in_new_tab'] ?? false) === true) {
$attributes[] = 'target="_blank"';
$attributes[] = 'rel="noopener noreferrer"';
}
}
// Atributo ARIA para accesibilidad
$ariaLabel = $data['content']['aria_label'] ?? 'Abrir formulario de contacto';
if (!empty($ariaLabel)) {
$attributes[] = sprintf('aria-label="%s"', esc_attr($ariaLabel));
}
return !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
}
/**
* Construir contenido del botón
*
* @param array $data Datos del componente
* @return string HTML del contenido
*/
private function buildContent(array $data): string
{
$html = '';
// Ícono (si está habilitado)
if ($this->shouldShowIcon($data)) {
$html .= $this->buildIcon($data);
}
// Texto del botón
$buttonText = $data['content']['button_text'] ?? "Let's Talk";
$html .= esc_html($buttonText);
return $html;
}
/**
* Verificar si debe mostrar el ícono
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowIcon(array $data): bool
{
return ($data['content']['show_icon'] ?? true) === true;
}
/**
* Construir ícono del componente
*
* @param array $data Datos del componente
* @return string HTML del ícono
*/
private function buildIcon(array $data): string
{
$iconClass = $data['content']['icon_class'] ?? 'bi-lightning-charge-fill';
// Asegurar prefijo 'bi-'
if (strpos($iconClass, 'bi-') !== 0) {
$iconClass = 'bi-' . $iconClass;
}
return sprintf(
'<i class="bi %s"></i>',
esc_attr($iconClass)
);
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CtaPost\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* CtaPostRenderer - Renderiza CTA promocional debajo del contenido
*
* RESPONSABILIDAD: Generar HTML y CSS del componente CTA Post
*
* CARACTERISTICAS:
* - Gradiente configurable
* - Layout responsive (2 columnas en desktop)
* - Boton CTA con icono
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\CtaPost\Infrastructure\Ui
*/
final class CtaPostRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'cta-post';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$effects = $data['visual_effects'] ?? [];
$spacing = $data['spacing'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
// Container values
$gradientStart = $colors['gradient_start'] ?? '#FF8600';
$gradientEnd = $colors['gradient_end'] ?? '#FFB800';
$gradientAngle = $effects['gradient_angle'] ?? '135deg';
$borderRadius = $effects['border_radius'] ?? '12px';
$boxShadow = $effects['box_shadow'] ?? '0 8px 24px rgba(255, 133, 0, 0.3)';
$containerPadding = $spacing['container_padding'] ?? '2rem';
// Button values
$buttonBgColor = $colors['button_bg_color'] ?? '#FF8600';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBgColor = $colors['button_hover_bg'] ?? '#e67a00';
$buttonBorderRadius = $effects['button_border_radius'] ?? '8px';
// Container - gradient background with box-shadow and border-radius
$cssRules[] = $this->cssGenerator->generate('.cta-post-container', [
'background' => "linear-gradient({$gradientAngle}, {$gradientStart} 0%, {$gradientEnd} 100%)",
'box-shadow' => $boxShadow,
'border-radius' => $borderRadius,
'padding' => $containerPadding,
]);
// Button styles (matching template .cta-button) - Using !important to override Bootstrap btn-light
$cssRules[] = ".cta-post-container .cta-button {
background-color: {$buttonBgColor} !important;
color: {$buttonTextColor} !important;
font-weight: 600;
padding: 0.75rem 2rem;
border: none !important;
border-radius: {$buttonBorderRadius};
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}";
// Button hover state
$cssRules[] = ".cta-post-container .cta-button:hover {
background-color: {$buttonHoverBgColor};
color: {$buttonTextColor};
}";
// Responsive: button full width on mobile
$cssRules[] = "@media (max-width: 768px) {
.cta-post-container .cta-button {
width: 100%;
margin-top: 1rem;
}
}";
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnDesktop = $showOnDesktop === true || $showOnDesktop === '1' || $showOnDesktop === 1;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$showOnMobile = $showOnMobile === true || $showOnMobile === '1' || $showOnMobile === 1;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.cta-post-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.cta-post-container { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$title = $content['title'] ?? 'Accede a 200,000+ Análisis de Precios Unitarios';
$description = $content['description'] ?? '';
$buttonText = $content['button_text'] ?? 'Ver Catálogo Completo';
$buttonUrl = $content['button_url'] ?? '#';
$buttonIcon = $content['button_icon'] ?? 'bi-arrow-right';
$html = '<div class="my-5 cta-post-container">';
$html .= ' <div class="row align-items-center">';
// Left column - Content
$html .= ' <div class="col-md-8">';
$html .= sprintf(
' <h3 class="h4 fw-bold text-white mb-2">%s</h3>',
esc_html($title)
);
if (!empty($description)) {
$html .= sprintf(
' <p class="text-white mb-md-0">%s</p>',
esc_html($description)
);
}
$html .= ' </div>';
// Right column - Button
$html .= ' <div class="col-md-4 text-md-end mt-3 mt-md-0">';
$html .= sprintf(
' <a href="%s" class="cta-button">%s',
esc_url($buttonUrl),
esc_html($buttonText)
);
if (!empty($buttonIcon)) {
$html .= sprintf(' <i class="bi %s ms-2"></i>', esc_attr($buttonIcon));
}
$html .= '</a>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\FeaturedImage\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* FeaturedImageRenderer - Renderiza la imagen destacada del post
*
* RESPONSABILIDAD: Generar HTML y CSS de la imagen destacada
*
* CARACTERISTICAS:
* - Integracion con get_the_post_thumbnail()
* - Estilos configurables desde BD
* - Efecto hover opcional
* - Soporte responsive
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar featured image)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\FeaturedImage\Infrastructure\Ui
*/
final class FeaturedImageRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
if (!$this->hasPostThumbnail()) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'featured-image';
}
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function hasPostThumbnail(): bool
{
return is_singular() && has_post_thumbnail();
}
private function generateCSS(array $data): string
{
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$marginTop = $spacing['margin_top'] ?? '1rem';
$marginBottom = $spacing['margin_bottom'] ?? '2rem';
$borderRadius = $effects['border_radius'] ?? '12px';
$boxShadow = $effects['box_shadow'] ?? '0 8px 24px rgba(0, 0, 0, 0.1)';
$hoverEffect = $effects['hover_effect'] ?? true;
$hoverScale = $effects['hover_scale'] ?? '1.02';
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$cssRules = [];
// Container styles
$cssRules[] = $this->cssGenerator->generate('.featured-image-container', [
'border-radius' => $borderRadius,
'overflow' => 'hidden',
'box-shadow' => $boxShadow,
'margin-top' => $marginTop,
'margin-bottom' => $marginBottom,
'transition' => "transform {$transitionDuration} ease, box-shadow {$transitionDuration} ease",
]);
// Image styles
$cssRules[] = $this->cssGenerator->generate('.featured-image-container img', [
'width' => '100%',
'height' => 'auto',
'display' => 'block',
'transition' => "transform {$transitionDuration} ease",
]);
// Hover effect
if ($hoverEffect) {
$cssRules[] = $this->cssGenerator->generate('.featured-image-container:hover', [
'box-shadow' => '0 12px 32px rgba(0, 0, 0, 0.15)',
]);
$cssRules[] = $this->cssGenerator->generate('.featured-image-container:hover img', [
'transform' => "scale({$hoverScale})",
]);
}
// Link styles (remove default link styling)
$cssRules[] = $this->cssGenerator->generate('.featured-image-container a', [
'display' => 'block',
'line-height' => '0',
]);
// Responsive visibility
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 767.98px) {
.featured-image-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 768px) {
.featured-image-container { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$imageSize = $content['image_size'] ?? 'roi-featured-large';
$lazyLoading = $content['lazy_loading'] ?? true;
$linkToMedia = $content['link_to_media'] ?? false;
$imgAttr = [
'class' => 'img-fluid featured-image',
'alt' => get_the_title(),
];
if ($lazyLoading) {
$imgAttr['loading'] = 'lazy';
}
$thumbnail = get_the_post_thumbnail(null, $imageSize, $imgAttr);
if (empty($thumbnail)) {
return '';
}
$html = '<div class="featured-image-container">';
if ($linkToMedia) {
$fullImageUrl = get_the_post_thumbnail_url(null, 'full');
$html .= sprintf(
'<a href="%s" target="_blank" rel="noopener" aria-label="%s">',
esc_url($fullImageUrl),
esc_attr__('Ver imagen en tamano completo', 'roi-theme')
);
}
$html .= $thumbnail;
if ($linkToMedia) {
$html .= '</a>';
}
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\Footer\Infrastructure\Api\Wordpress;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* NewsletterAjaxHandler - Procesa suscripciones al newsletter
*
* RESPONSABILIDAD: Recibir email y enviarlo al webhook configurado
*
* SEGURIDAD:
* - Verifica nonce
* - Webhook URL nunca se expone al cliente
* - Rate limiting basico
* - Sanitizacion de inputs
*
* @package ROITheme\Public\Footer\Infrastructure\Api\WordPress
*/
final class NewsletterAjaxHandler
{
private const NONCE_ACTION = 'roi_newsletter_nonce';
private const COMPONENT_NAME = 'footer';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Registrar hooks AJAX
*/
public function register(): void
{
add_action('wp_ajax_roi_newsletter_subscribe', [$this, 'handleSubscribe']);
add_action('wp_ajax_nopriv_roi_newsletter_subscribe', [$this, 'handleSubscribe']);
}
/**
* Procesar suscripcion
*/
public function handleSubscribe(): void
{
// 1. Verificar nonce
$nonce = sanitize_text_field($_POST['nonce'] ?? '');
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
wp_send_json_error([
'message' => __('Error de seguridad. Por favor recarga la pagina.', 'roi-theme')
], 403);
return;
}
// 2. Rate limiting (1 suscripcion por IP cada 60 segundos)
if (!$this->checkRateLimit()) {
wp_send_json_error([
'message' => __('Por favor espera un momento antes de intentar de nuevo.', 'roi-theme')
], 429);
return;
}
// 3. Validar y sanitizar campos
$email = sanitize_email($_POST['email'] ?? '');
$name = sanitize_text_field($_POST['name'] ?? '');
$whatsapp = sanitize_text_field($_POST['whatsapp'] ?? '');
if (empty($email) || !is_email($email)) {
wp_send_json_error([
'message' => __('Por favor ingresa un email valido.', 'roi-theme')
], 422);
return;
}
// 4. Obtener configuracion del componente
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
wp_send_json_error([
'message' => __('Error de configuracion. Contacta al administrador.', 'roi-theme')
], 500);
return;
}
$newsletter = $settings['newsletter'] ?? [];
$webhookUrl = $newsletter['newsletter_webhook_url'] ?? '';
$successMsg = $newsletter['newsletter_success_message'] ?? __('Gracias por suscribirte!', 'roi-theme');
$errorMsg = $newsletter['newsletter_error_message'] ?? __('Error al suscribirse. Intenta de nuevo.', 'roi-theme');
if (empty($webhookUrl)) {
// Si no hay webhook, simular exito para UX pero loguear warning
error_log('ROI Theme Newsletter: No webhook URL configured');
wp_send_json_success([
'message' => $successMsg
]);
return;
}
// 5. Preparar payload
$payload = [
'email' => $email,
'name' => $name,
'whatsapp' => $whatsapp,
'source' => 'newsletter-footer',
'pageUrl' => sanitize_url($_POST['pageUrl'] ?? ''),
'pageTitle' => sanitize_text_field($_POST['pageTitle'] ?? ''),
'timestamp' => current_time('c'),
'timezone' => wp_timezone_string(),
'siteName' => get_bloginfo('name'),
'siteUrl' => home_url(),
];
// Debug: Log payload enviado
error_log('ROI Theme Newsletter: Enviando a webhook - ' . wp_json_encode($payload));
// 6. Enviar a webhook
$result = $this->sendToWebhook($webhookUrl, $payload);
if ($result['success']) {
wp_send_json_success([
'message' => $successMsg
]);
} else {
error_log('ROI Theme Newsletter webhook error: ' . $result['error']);
wp_send_json_error([
'message' => $errorMsg
], 500);
}
}
/**
* Enviar datos al webhook
*/
private function sendToWebhook(string $url, array $payload): array
{
error_log('ROI Theme Newsletter: Webhook URL - ' . $url);
$response = wp_remote_post($url, [
'timeout' => 30,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
error_log('ROI Theme Newsletter: WP Error - ' . $response->get_error_message());
return [
'success' => false,
'error' => $response->get_error_message()
];
}
$statusCode = wp_remote_retrieve_response_code($response);
$responseBody = wp_remote_retrieve_body($response);
error_log('ROI Theme Newsletter: Response Code - ' . $statusCode);
error_log('ROI Theme Newsletter: Response Body - ' . $responseBody);
if ($statusCode >= 200 && $statusCode < 300) {
return ['success' => true, 'error' => ''];
}
return [
'success' => false,
'error' => sprintf('HTTP %d: %s', $statusCode, wp_remote_retrieve_response_message($response))
];
}
/**
* Rate limiting por IP
*/
private function checkRateLimit(): bool
{
$ip = $this->getClientIP();
$transientKey = 'roi_newsletter_' . md5($ip);
$lastSubmit = get_transient($transientKey);
if ($lastSubmit !== false) {
return false;
}
set_transient($transientKey, time(), 60);
return true;
}
/**
* Obtener IP del cliente
*/
private function getClientIP(): string
{
$ip = '';
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
return $ip;
}
}

View File

@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\Footer\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* FooterRenderer - Renderiza el footer del sitio
*
* RESPONSABILIDAD: Generar HTML y CSS del footer con menus WP y newsletter
*
* SEGURIDAD:
* - Webhook URL nunca se expone al cliente
* - Escaping de todos los outputs
* - Nonce para formulario newsletter
*
* @package ROITheme\Public\Footer\Infrastructure\Ui
*/
final class FooterRenderer implements RendererInterface
{
private const NONCE_ACTION = 'roi_newsletter_nonce';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function supports(string $componentType): bool
{
return $componentType === 'footer';
}
public function render(Component $component): string
{
$data = $component->getData();
// Validar visibilidad
$visibility = $data['visibility'] ?? [];
if (!($visibility['is_enabled'] ?? true)) {
return '';
}
// Verificar visibilidad responsive
$showDesktop = $visibility['show_on_desktop'] ?? true;
$showMobile = $visibility['show_on_mobile'] ?? true;
if (!$showDesktop && !$showMobile) {
return '';
}
// Generar CSS
$css = $this->generateCSS($data, $showDesktop, $showMobile);
// Generar HTML
$html = $this->generateHTML($data);
// Generar JavaScript
$js = $this->generateJS($data);
return $css . $html . $js;
}
private function generateCSS(array $data, bool $showDesktop, bool $showMobile): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
// Valores con fallbacks
$bgColor = $colors['bg_color'] ?? '#212529';
$textColor = $colors['text_color'] ?? '#ffffff';
$titleColor = $colors['title_color'] ?? '#ffffff';
$linkColor = $colors['link_color'] ?? '#ffffff';
$linkHoverColor = $colors['link_hover_color'] ?? '#FF8600';
$inputBgColor = $colors['input_bg_color'] ?? '#ffffff';
$inputTextColor = $colors['input_text_color'] ?? '#212529';
$inputBorderColor = $colors['input_border_color'] ?? '#dee2e6';
$buttonBgColor = $colors['button_bg_color'] ?? '#0d6efd';
$buttonTextColor = $colors['button_text_color'] ?? '#ffffff';
$buttonHoverBg = $colors['button_hover_bg'] ?? '#0b5ed7';
$borderTopColor = $colors['border_top_color'] ?? 'rgba(255, 255, 255, 0.2)';
$paddingY = $spacing['padding_y'] ?? '3rem';
$marginTop = $spacing['margin_top'] ?? '0';
$widgetTitleMb = $spacing['widget_title_margin_bottom'] ?? '1rem';
$linkMb = $spacing['link_margin_bottom'] ?? '0.5rem';
$copyrightPy = $spacing['copyright_padding_y'] ?? '1.5rem';
$inputRadius = $effects['input_border_radius'] ?? '6px';
$buttonRadius = $effects['button_border_radius'] ?? '6px';
$transition = $effects['transition_duration'] ?? '0.3s';
$cssRules = [];
// Footer principal
$cssRules[] = $this->cssGenerator->generate('.roi-footer', [
'background-color' => $bgColor,
'color' => $textColor,
'padding-top' => $paddingY,
'padding-bottom' => $paddingY,
'margin-top' => $marginTop,
]);
// Grid custom para 3+3+3+4 = 13 columnas
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-grid', [
'display' => 'grid',
'grid-template-columns' => 'repeat(4, 1fr)',
'gap' => '2rem',
]);
// En desktop: distribucion 3+3+3+4
$cssRules[] = "@media (min-width: 768px) {
.roi-footer .footer-grid {
grid-template-columns: 23% 23% 23% 31%;
}
}";
// En mobile: 2 columnas
$cssRules[] = "@media (max-width: 767px) {
.roi-footer .footer-grid {
grid-template-columns: 1fr 1fr;
}
.roi-footer .footer-widget-newsletter {
grid-column: span 2;
}
}";
// Titulos de widgets
$cssRules[] = $this->cssGenerator->generate('.roi-footer .widget-title', [
'color' => $titleColor,
'font-size' => '1.25rem',
'font-weight' => '500',
'margin-bottom' => $widgetTitleMb,
]);
// Links de navegacion
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav', [
'list-style' => 'none',
'padding' => '0',
'margin' => '0',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav li', [
'margin-bottom' => $linkMb,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav a', [
'color' => $linkColor,
'text-decoration' => 'none',
'transition' => "color {$transition}",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-nav a:hover', [
'color' => $linkHoverColor,
]);
// Widget 1B spacing
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-widget-1b', [
'margin-top' => '1.5rem',
]);
// Newsletter description
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-description', [
'color' => $textColor,
'margin-bottom' => '1rem',
'opacity' => '0.9',
]);
// Input newsletter
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-input', [
'width' => '100%',
'padding' => '0.75rem 1rem',
'background-color' => $inputBgColor,
'color' => $inputTextColor,
'border' => "1px solid {$inputBorderColor}",
'border-radius' => $inputRadius,
'margin-bottom' => '0.75rem',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-input:focus', [
'outline' => 'none',
'border-color' => $buttonBgColor,
'box-shadow' => "0 0 0 0.2rem rgba(13, 110, 253, 0.25)",
]);
// Boton newsletter
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-btn', [
'width' => '100%',
'padding' => '0.75rem 1.5rem',
'background-color' => $buttonBgColor,
'color' => $buttonTextColor,
'border' => 'none',
'border-radius' => $buttonRadius,
'font-weight' => '500',
'cursor' => 'pointer',
'transition' => "background-color {$transition}",
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-btn:hover', [
'background-color' => $buttonHoverBg,
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-btn:disabled', [
'opacity' => '0.7',
'cursor' => 'not-allowed',
]);
// Mensaje newsletter
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-message', [
'margin-top' => '0.75rem',
'padding' => '0.5rem',
'border-radius' => '4px',
'font-size' => '0.875rem',
'display' => 'none',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-message.success', [
'background-color' => '#d1e7dd',
'color' => '#0f5132',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .newsletter-message.error', [
'background-color' => '#f8d7da',
'color' => '#842029',
]);
// Footer bottom (copyright)
$cssRules[] = $this->cssGenerator->generate('.roi-footer .footer-bottom', [
'border-top' => "1px solid {$borderTopColor}",
'padding-top' => $copyrightPy,
'margin-top' => '2rem',
'text-align' => 'center',
]);
$cssRules[] = $this->cssGenerator->generate('.roi-footer .copyright-text', [
'margin' => '0',
'opacity' => '0.9',
]);
// Responsive visibility
if (!$showDesktop) {
$cssRules[] = "@media (min-width: 992px) { .roi-footer { display: none !important; } }";
}
if (!$showMobile) {
$cssRules[] = "@media (max-width: 991px) { .roi-footer { display: none !important; } }";
}
return '<style>' . implode("\n", $cssRules) . '</style>';
}
private function generateHTML(array $data): string
{
$widget1 = $data['widget_1'] ?? [];
$widget1b = $data['widget_1b'] ?? [];
$widget2 = $data['widget_2'] ?? [];
$widget3 = $data['widget_3'] ?? [];
$newsletter = $data['newsletter'] ?? [];
$footerBottom = $data['footer_bottom'] ?? [];
$widget1Visible = $this->toBool($widget1['widget_1_visible'] ?? true);
$widget2Visible = $this->toBool($widget2['widget_2_visible'] ?? true);
$widget3Visible = $this->toBool($widget3['widget_3_visible'] ?? true);
$newsletterVisible = $this->toBool($newsletter['newsletter_visible'] ?? true);
$widget1Title = esc_html($widget1['widget_1_title'] ?? 'Recursos');
$widget1bTitle = esc_html($widget1b['widget_1b_title'] ?? 'Bases de datos');
$widget2Title = esc_html($widget2['widget_2_title'] ?? 'Soporte');
$widget3Title = esc_html($widget3['widget_3_title'] ?? 'Empresa');
$newsletterTitle = esc_html($newsletter['newsletter_title'] ?? 'Suscribete al Newsletter');
$newsletterDesc = esc_html($newsletter['newsletter_description'] ?? 'Recibe las ultimas actualizaciones.');
$newsletterNamePlaceholder = esc_attr($newsletter['newsletter_name_placeholder'] ?? 'Nombre');
$newsletterEmailPlaceholder = esc_attr($newsletter['newsletter_email_placeholder'] ?? 'Email');
$newsletterWhatsappPlaceholder = esc_attr($newsletter['newsletter_whatsapp_placeholder'] ?? 'WhatsApp');
$newsletterBtnText = esc_html($newsletter['newsletter_button_text'] ?? 'Suscribirse');
$copyrightText = esc_html($footerBottom['copyright_text'] ?? date('Y') . ' Todos los derechos reservados.');
$nonce = wp_create_nonce(self::NONCE_ACTION);
$ajaxUrl = admin_url('admin-ajax.php');
$html = '<footer class="roi-footer">';
$html .= '<div class="container">';
$html .= '<div class="footer-grid">';
// Columna 1: Widget 1 + Widget 1B
if ($widget1Visible) {
$html .= '<div class="footer-column footer-column-1">';
// Widget 1
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<h5 class="widget-title">' . $widget1Title . '</h5>';
$html .= $this->renderMenu('footer_menu_1');
$html .= '</div>';
// Widget 1B - Solo si tiene menu asignado
if (has_nav_menu('footer_menu_4')) {
$html .= '<div class="footer-widget footer-widget-menu footer-widget-1b">';
$html .= '<h5 class="widget-title">' . $widget1bTitle . '</h5>';
$html .= $this->renderMenu('footer_menu_4');
$html .= '</div>';
}
$html .= '</div>';
}
// Widget 2
if ($widget2Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<h5 class="widget-title">' . $widget2Title . '</h5>';
$html .= $this->renderMenu('footer_menu_2');
$html .= '</div>';
}
// Widget 3
if ($widget3Visible) {
$html .= '<div class="footer-widget footer-widget-menu">';
$html .= '<h5 class="widget-title">' . $widget3Title . '</h5>';
$html .= $this->renderMenu('footer_menu_3');
$html .= '</div>';
}
// Widget Newsletter
if ($newsletterVisible) {
$html .= '<div class="footer-widget footer-widget-newsletter">';
$html .= '<h5 class="widget-title">' . $newsletterTitle . '</h5>';
$html .= '<p class="newsletter-description">' . $newsletterDesc . '</p>';
$html .= '<form id="roi-newsletter-form" class="newsletter-form">';
$html .= '<input type="hidden" name="action" value="roi_newsletter_subscribe">';
$html .= '<input type="hidden" name="nonce" value="' . esc_attr($nonce) . '">';
$html .= '<input type="text" name="name" class="newsletter-input" placeholder="' . $newsletterNamePlaceholder . '">';
$html .= '<input type="email" name="email" class="newsletter-input" placeholder="' . $newsletterEmailPlaceholder . '" required>';
$html .= '<input type="tel" name="whatsapp" class="newsletter-input" placeholder="' . $newsletterWhatsappPlaceholder . '">';
$html .= '<button type="submit" class="newsletter-btn">' . $newsletterBtnText . '</button>';
$html .= '<div class="newsletter-message"></div>';
$html .= '</form>';
$html .= '</div>';
}
$html .= '</div>'; // .footer-grid
// Footer bottom
$html .= '<div class="footer-bottom">';
$html .= '<p class="copyright-text">&copy; ' . $copyrightText . '</p>';
$html .= '</div>';
$html .= '</div>'; // .container
$html .= '</footer>';
return $html;
}
private function renderMenu(string $menuLocation): string
{
if (!has_nav_menu($menuLocation)) {
return '<p class="text-muted">Menu no asignado</p>';
}
return wp_nav_menu([
'theme_location' => $menuLocation,
'container' => false,
'menu_class' => 'footer-nav',
'fallback_cb' => false,
'echo' => false,
'depth' => 1,
]) ?: '';
}
private function generateJS(array $data): string
{
$newsletter = $data['newsletter'] ?? [];
$successMsg = esc_js($newsletter['newsletter_success_message'] ?? 'Gracias por suscribirte!');
$errorMsg = esc_js($newsletter['newsletter_error_message'] ?? 'Error al suscribirse. Intenta de nuevo.');
$ajaxUrl = admin_url('admin-ajax.php');
$js = <<<JS
<script>
(function() {
const form = document.getElementById('roi-newsletter-form');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const btn = form.querySelector('.newsletter-btn');
const msgDiv = form.querySelector('.newsletter-message');
const emailInput = form.querySelector('input[name="email"]');
const originalText = btn.textContent;
// Reset message
msgDiv.style.display = 'none';
msgDiv.className = 'newsletter-message';
// Validate email
if (!emailInput.value || !emailInput.validity.valid) {
msgDiv.textContent = 'Por favor ingresa un email valido';
msgDiv.classList.add('error');
msgDiv.style.display = 'block';
return;
}
// Disable button
btn.disabled = true;
btn.textContent = 'Enviando...';
try {
const formData = new FormData(form);
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
const response = await fetch('{$ajaxUrl}', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
msgDiv.textContent = '{$successMsg}';
msgDiv.classList.add('success');
emailInput.value = '';
} else {
msgDiv.textContent = result.data?.message || '{$errorMsg}';
msgDiv.classList.add('error');
}
} catch (error) {
msgDiv.textContent = '{$errorMsg}';
msgDiv.classList.add('error');
}
msgDiv.style.display = 'block';
btn.disabled = false;
btn.textContent = originalText;
});
})();
</script>
JS;
return $js;
}
private function toBool($value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\Hero\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* Class HeroRenderer
*
* Renderizador del componente Hero para el frontend.
*
* Responsabilidades:
* - Renderizar HTML del hero section con título del post/página
* - Mostrar badges de categorías (dinámicos desde WordPress)
* - Delegar generación de CSS a CSSGeneratorInterface
* - Validar visibilidad (is_enabled, show_on_pages, responsive)
* - Manejar visibilidad responsive con clases Bootstrap
*
* NO responsable de:
* - Generar string CSS (delega a CSSGeneratorService)
* - Persistir datos
* - Lógica de negocio
*
* @package ROITheme\Public\Hero\Infrastructure\Ui
*/
final class HeroRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'hero';
}
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === 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() || is_home();
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$typography = $data['typography'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$gradientStart = $colors['gradient_start'] ?? '#1e3a5f';
$gradientEnd = $colors['gradient_end'] ?? '#2c5282';
$titleColor = $colors['title_color'] ?? '#FFFFFF';
$badgeBgColor = $colors['badge_bg_color'] ?? '#FFFFFF';
$badgeTextColor = $colors['badge_text_color'] ?? '#FFFFFF';
$badgeIconColor = $colors['badge_icon_color'] ?? '#FFB800';
$badgeHoverBg = $colors['badge_hover_bg'] ?? '#FF8600';
$titleFontSize = $typography['title_font_size'] ?? '2.5rem';
$titleFontSizeMobile = $typography['title_font_size_mobile'] ?? '1.75rem';
$titleFontWeight = $typography['title_font_weight'] ?? '700';
$titleLineHeight = $typography['title_line_height'] ?? '1.4';
$badgeFontSize = $typography['badge_font_size'] ?? '0.813rem';
$paddingVertical = $spacing['padding_vertical'] ?? '3rem';
$marginBottom = $spacing['margin_bottom'] ?? '1.5rem';
$badgePadding = $spacing['badge_padding'] ?? '0.375rem 0.875rem';
$badgeBorderRadius = $spacing['badge_border_radius'] ?? '20px';
$boxShadow = $effects['box_shadow'] ?? '0 4px 16px rgba(30, 58, 95, 0.25)';
$titleTextShadow = $effects['title_text_shadow'] ?? '1px 1px 2px rgba(0, 0, 0, 0.2)';
$badgeBackdropBlur = $effects['badge_backdrop_blur'] ?? '10px';
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$cssRules = [];
$cssRules[] = $this->cssGenerator->generate('.hero-section', [
'background' => "linear-gradient(135deg, {$gradientStart} 0%, {$gradientEnd} 100%)",
'box-shadow' => $boxShadow,
'padding' => "{$paddingVertical} 0",
'margin-bottom' => $marginBottom,
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__title', [
'color' => "{$titleColor} !important",
'font-weight' => $titleFontWeight,
'font-size' => $titleFontSize,
'line-height' => $titleLineHeight,
'text-shadow' => $titleTextShadow,
'margin-bottom' => '0',
'text-align' => 'center',
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__badge', [
'background' => $this->hexToRgba($badgeBgColor, 0.15),
'backdrop-filter' => "blur({$badgeBackdropBlur})",
'-webkit-backdrop-filter' => "blur({$badgeBackdropBlur})",
'border' => '1px solid ' . $this->hexToRgba($badgeBgColor, 0.2),
'color' => $this->hexToRgba($badgeTextColor, 0.95),
'padding' => $badgePadding,
'border-radius' => $badgeBorderRadius,
'font-size' => $badgeFontSize,
'font-weight' => '500',
'text-decoration' => 'none',
'display' => 'inline-block',
'transition' => 'all 0.3s ease',
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__badge:hover', [
'background' => $this->hexToRgba($badgeHoverBg, 0.2),
'border-color' => $this->hexToRgba($badgeHoverBg, 0.4),
'color' => '#ffffff',
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__badge i', [
'color' => $badgeIconColor,
]);
$cssRules[] = "@media (max-width: 767.98px) {
.hero-section__title {
font-size: {$titleFontSizeMobile};
}
}";
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 767.98px) {
.hero-section { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 768px) {
.hero-section { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$showCategories = $content['show_categories'] ?? true;
$showBadgeIcon = $content['show_badge_icon'] ?? true;
$badgeIconClass = $content['badge_icon_class'] ?? 'bi-folder-fill';
$titleTag = $content['title_tag'] ?? 'h1';
$allowedTags = ['h1', 'h2', 'div'];
if (!in_array($titleTag, $allowedTags, true)) {
$titleTag = 'h1';
}
$title = is_singular() ? get_the_title() : '';
if (empty($title)) {
$title = wp_title('', false);
}
$html = '<div class="container-fluid hero-section">';
$html .= '<div class="container">';
if ($showCategories && is_single()) {
$categories = get_the_category();
if (!empty($categories)) {
$html .= '<div class="mb-3 d-flex justify-content-center">';
$html .= '<div class="d-flex gap-2 flex-wrap justify-content-center">';
foreach ($categories as $category) {
$categoryLink = esc_url(get_category_link($category->term_id));
$categoryName = esc_html($category->name);
$iconHtml = $showBadgeIcon
? '<i class="bi ' . esc_attr($badgeIconClass) . ' me-1"></i>'
: '';
$html .= sprintf(
'<a href="%s" class="hero-section__badge">%s%s</a>',
$categoryLink,
$iconHtml,
$categoryName
);
}
$html .= '</div>';
$html .= '</div>';
}
}
$html .= sprintf(
'<%s class="hero-section__title">%s</%s>',
$titleTag,
esc_html($title),
$titleTag
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function hexToRgba(string $hex, float $alpha): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return "rgba({$r}, {$g}, {$b}, {$alpha})";
}
/**
* Genera clases Bootstrap de visibilidad responsive
*
* @param bool $desktop Si debe mostrarse en desktop
* @param bool $mobile Si debe mostrarse en mobile
* @return string|null Clases Bootstrap o null si visible en todos
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if ($desktop && $mobile) {
return null;
}
if ($desktop && !$mobile) {
return 'd-none d-md-block';
}
if (!$desktop && $mobile) {
return 'd-block d-md-none';
}
return 'd-none';
}
}

View File

@@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\herosection\infrastructure\ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
/**
* HeroSectionRenderer - Renderiza la sección hero con badges y título
*
* RESPONSABILIDAD: Generar HTML de la sección hero
*
* CARACTERÍSTICAS:
* - Badges de categorías con múltiples fuentes de datos
* - Título H1 con gradiente opcional
* - Múltiples tipos de fondo (color, gradiente, imagen)
* - Lógica condicional de visibilidad por tipo de página
*
* @package ROITheme\Public\HeroSection\Presentation
*/
final class HeroSectionRenderer implements RendererInterface
{
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$classes = $this->buildSectionClasses($data);
$styles = $this->buildInlineStyles($data);
$html = sprintf(
'<div class="%s"%s>',
esc_attr($classes),
$styles ? ' style="' . esc_attr($styles) . '"' : ''
);
$html .= '<div class="container">';
// Categories badges
if ($this->shouldShowCategories($data)) {
$html .= $this->buildCategoriesBadges($data);
}
// Title
$html .= $this->buildTitle($data);
$html .= '</div>';
$html .= '</div>';
// Custom styles
$html .= $this->buildCustomStyles($data);
return $html;
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'home':
return is_front_page();
case 'posts':
return is_single() && get_post_type() === 'post';
case 'pages':
return is_page();
case 'custom':
$postTypes = $data['visibility']['custom_post_types'] ?? '';
$allowedTypes = array_map('trim', explode(',', $postTypes));
return in_array(get_post_type(), $allowedTypes, true);
default:
return true;
}
}
private function shouldShowCategories(array $data): bool
{
return isset($data['categories']['show_categories']) &&
$data['categories']['show_categories'] === true;
}
private function buildSectionClasses(array $data): string
{
$classes = ['container-fluid', 'hero-title'];
$paddingClass = $this->getPaddingClass($data['styles']['padding_vertical'] ?? 'normal');
$classes[] = $paddingClass;
$marginClass = $this->getMarginClass($data['styles']['margin_bottom'] ?? 'normal');
if ($marginClass) {
$classes[] = $marginClass;
}
return implode(' ', $classes);
}
private function getPaddingClass(string $padding): string
{
$paddings = [
'compact' => 'py-3',
'normal' => 'py-5',
'spacious' => 'py-6',
'extra-spacious' => 'py-7'
];
return $paddings[$padding] ?? 'py-5';
}
private function getMarginClass(string $margin): string
{
$margins = [
'none' => '',
'small' => 'mb-2',
'normal' => 'mb-4',
'large' => 'mb-5'
];
return $margins[$margin] ?? 'mb-4';
}
private function buildInlineStyles(array $data): string
{
$styles = [];
$backgroundType = $data['styles']['background_type'] ?? 'gradient';
switch ($backgroundType) {
case 'color':
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
$styles[] = "background-color: {$bgColor}";
break;
case 'gradient':
$startColor = $data['styles']['gradient_start_color'] ?? '#1e3a5f';
$endColor = $data['styles']['gradient_end_color'] ?? '#2c5282';
$angle = $data['styles']['gradient_angle'] ?? 135;
$styles[] = "background: linear-gradient({$angle}deg, {$startColor}, {$endColor})";
break;
case 'image':
$imageUrl = $data['styles']['background_image_url'] ?? '';
if (!empty($imageUrl)) {
$styles[] = "background-image: url('" . esc_url($imageUrl) . "')";
$styles[] = "background-size: cover";
$styles[] = "background-position: center";
$styles[] = "background-repeat: no-repeat";
if (isset($data['styles']['background_overlay']) && $data['styles']['background_overlay']) {
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
$styles[] = "position: relative";
}
}
break;
}
// Text color
if (!empty($data['styles']['text_color'])) {
$styles[] = 'color: ' . $data['styles']['text_color'];
}
return implode('; ', $styles);
}
private function buildCategoriesBadges(array $data): string
{
$categories = $this->getCategories($data);
if (empty($categories)) {
return '';
}
$maxCategories = $data['categories']['max_categories'] ?? 5;
$categories = array_slice($categories, 0, $maxCategories);
$alignment = $data['categories']['categories_alignment'] ?? 'center';
$alignmentClasses = [
'left' => 'justify-content-start',
'center' => 'justify-content-center',
'right' => 'justify-content-end'
];
$alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center';
$icon = $data['categories']['category_icon'] ?? 'bi-folder-fill';
if (strpos($icon, 'bi-') !== 0) {
$icon = 'bi-' . $icon;
}
$html = sprintf('<div class="mb-3 d-flex %s">', esc_attr($alignmentClass));
$html .= '<div class="d-flex gap-2 flex-wrap justify-content-center">';
foreach ($categories as $category) {
$html .= sprintf(
'<a href="%s" class="category-badge category-badge-hero"><i class="bi %s me-1"></i>%s</a>',
esc_url($category['url']),
esc_attr($icon),
esc_html($category['name'])
);
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function getCategories(array $data): array
{
$source = $data['categories']['categories_source'] ?? 'post_categories';
switch ($source) {
case 'post_categories':
return $this->getPostCategories();
case 'post_tags':
return $this->getPostTags();
case 'custom_taxonomy':
$taxonomy = $data['categories']['custom_taxonomy_name'] ?? '';
return $this->getCustomTaxonomyTerms($taxonomy);
case 'custom_list':
$list = $data['categories']['custom_categories_list'] ?? '';
return $this->parseCustomCategoriesList($list);
default:
return [];
}
}
private function getPostCategories(): array
{
$categories = get_the_category();
if (empty($categories)) {
return [];
}
$result = [];
foreach ($categories as $category) {
$result[] = [
'name' => $category->name,
'url' => get_category_link($category->term_id)
];
}
return $result;
}
private function getPostTags(): array
{
$tags = get_the_tags();
if (empty($tags)) {
return [];
}
$result = [];
foreach ($tags as $tag) {
$result[] = [
'name' => $tag->name,
'url' => get_tag_link($tag->term_id)
];
}
return $result;
}
private function getCustomTaxonomyTerms(string $taxonomy): array
{
if (empty($taxonomy)) {
return [];
}
$terms = get_the_terms(get_the_ID(), $taxonomy);
if (empty($terms) || is_wp_error($terms)) {
return [];
}
$result = [];
foreach ($terms as $term) {
$result[] = [
'name' => $term->name,
'url' => get_term_link($term)
];
}
return $result;
}
private function parseCustomCategoriesList(string $list): array
{
if (empty($list)) {
return [];
}
$lines = explode("\n", $list);
$result = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
$parts = explode('|', $line);
if (count($parts) >= 2) {
$result[] = [
'name' => trim($parts[0]),
'url' => trim($parts[1])
];
}
}
return $result;
}
private function buildTitle(array $data): string
{
$titleText = $this->getTitleText($data);
if (empty($titleText)) {
return '';
}
$titleTag = $data['title']['title_tag'] ?? 'h1';
$titleClasses = $data['title']['title_classes'] ?? 'display-5 fw-bold';
$alignment = $data['title']['title_alignment'] ?? 'center';
$alignmentClasses = [
'left' => 'text-start',
'center' => 'text-center',
'right' => 'text-end'
];
$alignmentClass = $alignmentClasses[$alignment] ?? 'text-center';
$classes = trim($titleClasses . ' ' . $alignmentClass);
$titleStyle = '';
if (isset($data['title']['enable_gradient']) && $data['title']['enable_gradient']) {
$titleStyle = $this->buildGradientStyle($data);
$classes .= ' roi-gradient-text';
}
return sprintf(
'<%s class="%s"%s>%s</%s>',
esc_attr($titleTag),
esc_attr($classes),
$titleStyle ? ' style="' . esc_attr($titleStyle) . '"' : '',
esc_html($titleText),
esc_attr($titleTag)
);
}
private function getTitleText(array $data): string
{
$source = $data['title']['title_source'] ?? 'post_title';
switch ($source) {
case 'post_title':
return get_the_title();
case 'custom_field':
$fieldName = $data['title']['custom_field_name'] ?? '';
if (!empty($fieldName)) {
$value = get_post_meta(get_the_ID(), $fieldName, true);
return is_string($value) ? $value : '';
}
return '';
case 'custom_text':
return $data['title']['custom_text'] ?? '';
default:
return get_the_title();
}
}
private function buildGradientStyle(array $data): string
{
$startColor = $data['title']['gradient_color_start'] ?? '#1e3a5f';
$endColor = $data['title']['gradient_color_end'] ?? '#FF8600';
$direction = $data['title']['gradient_direction'] ?? 'to-right';
$directions = [
'to-right' => 'to right',
'to-left' => 'to left',
'to-bottom' => 'to bottom',
'to-top' => 'to top',
'diagonal' => '135deg'
];
$gradientDirection = $directions[$direction] ?? 'to right';
return "background: linear-gradient({$gradientDirection}, {$startColor}, {$endColor}); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;";
}
private function buildCustomStyles(array $data): string
{
$badgeBg = $data['styles']['category_badge_background'] ?? 'rgba(255, 255, 255, 0.2)';
$badgeTextColor = $data['styles']['category_badge_text_color'] ?? '#FFFFFF';
$badgeBlur = isset($data['styles']['category_badge_blur']) && $data['styles']['category_badge_blur'];
$blurStyle = $badgeBlur ? 'backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);' : '';
$overlayStyle = '';
if (($data['styles']['background_type'] ?? '') === 'image' &&
isset($data['styles']['background_overlay']) &&
$data['styles']['background_overlay']) {
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
$overlayStyle = <<<CSS
.hero-title::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, {$opacity});
z-index: 0;
}
.hero-title > .container {
position: relative;
z-index: 1;
}
CSS;
}
return <<<STYLES
<style>
.category-badge-hero {
background-color: {$badgeBg};
color: {$badgeTextColor};
padding: 0.5rem 1rem;
border-radius: 2rem;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
display: inline-flex;
align-items: center;
transition: all 0.3s ease;
{$blurStyle}
}
.category-badge-hero:hover {
background-color: rgba(255, 134, 0, 0.3);
color: {$badgeTextColor};
transform: translateY(-2px);
}
.roi-gradient-text {
display: inline-block;
}
{$overlayStyle}
</style>
STYLES;
}
public function supports(string $componentType): bool
{
return $componentType === 'hero-section';
}
}

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\Navbar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use Walker_Nav_Menu;
/**
* NavbarRenderer - Renderiza el menú de navegación principal
*
* RESPONSABILIDAD: Generar HTML del menú de navegación WordPress
*
* CARACTERÍSTICAS:
* - Integración con wp_nav_menu()
* - Walker personalizado para Bootstrap 5
* - Soporte para submenús desplegables
* - Responsive con navbar-toggler
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar navbar)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\Navbar\Infrastructure\Ui
*/
final class NavbarRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildMenu($data);
return sprintf(
"<style>%s</style>\n%s",
$css,
$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;
}
/**
* Generar CSS usando CSSGeneratorService
*
* @param array $data Datos del componente
* @return string CSS generado
*/
private function generateCSS(array $data): string
{
$css = '';
// Obtener valores de configuración
$stickyEnabled = $data['visibility']['sticky_enabled'] ?? true;
$paddingVertical = $data['layout']['padding_vertical'] ?? '0.75rem 0';
$zIndex = $data['layout']['z_index'] ?? '1030';
$bgColor = $data['colors']['background_color'] ?? '#1e3a5f';
$boxShadow = $data['colors']['box_shadow'] ?? '0 4px 12px rgba(30, 58, 95, 0.15)';
$linkTextColor = $data['links']['text_color'] ?? '#FFFFFF';
$linkHoverColor = $data['links']['hover_color'] ?? '#FF8600';
$linkActiveColor = $data['links']['active_color'] ?? '#FF8600';
$linkFontSize = $data['links']['font_size'] ?? '0.9rem';
$linkFontWeight = $data['links']['font_weight'] ?? '500';
$linkPadding = $data['links']['padding'] ?? '0.5rem 0.65rem';
$linkBorderRadius = $data['links']['border_radius'] ?? '4px';
$showUnderlineEffect = $data['links']['show_underline_effect'] ?? true;
$underlineColor = $data['links']['underline_color'] ?? '#FF8600';
// Estilos del navbar container
$navbarStyles = [
'background-color' => $bgColor . ' !important',
'box-shadow' => $boxShadow,
'padding' => $paddingVertical,
'transition' => 'all 0.3s ease',
];
if ($stickyEnabled) {
$navbarStyles['position'] = 'sticky';
$navbarStyles['top'] = '0';
$navbarStyles['z-index'] = $zIndex;
}
$css .= $this->cssGenerator->generate('.navbar', $navbarStyles);
// Efecto scrolled del navbar
$css .= "\n" . $this->cssGenerator->generate('.navbar.scrolled', [
'box-shadow' => '0 6px 20px rgba(30, 58, 95, 0.25)',
]);
// Estilos de los enlaces del navbar
$navLinkStyles = [
'color' => 'rgba(255, 255, 255, 0.9) !important',
'font-weight' => $linkFontWeight,
'position' => 'relative',
'padding' => $linkPadding . ' !important',
'transition' => 'all 0.3s ease',
'font-size' => $linkFontSize,
'white-space' => 'nowrap',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .nav-link', $navLinkStyles);
// Efecto de subrayado (::after pseudo-element)
if ($showUnderlineEffect) {
$css .= "\n.navbar .nav-link::after {";
$css .= "\n content: '';";
$css .= "\n position: absolute;";
$css .= "\n bottom: 0;";
$css .= "\n left: 50%;";
$css .= "\n transform: translateX(-50%) scaleX(0);";
$css .= "\n width: 80%;";
$css .= "\n height: 2px;";
$css .= "\n background: {$underlineColor};";
$css .= "\n transition: transform 0.3s ease;";
$css .= "\n}";
$css .= "\n.navbar .nav-link:hover::after {";
$css .= "\n transform: translateX(-50%) scaleX(1);";
$css .= "\n}";
}
// Estilos hover y focus de los enlaces
$navLinkHoverStyles = [
'color' => $linkHoverColor . ' !important',
'background-color' => 'rgba(255, 133, 0, 0.1)',
'border-radius' => $linkBorderRadius,
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .nav-link:hover, .navbar .nav-link:focus', $navLinkHoverStyles);
// Estilos de enlaces activos
$navLinkActiveStyles = [
'color' => $linkActiveColor . ' !important',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .nav-link.active, .navbar .nav-item.current-menu-item > .nav-link', $navLinkActiveStyles);
// Estilos del dropdown menu
$dropdownMaxHeight = $data['visual_effects']['dropdown_max_height'] ?? '300px';
$dropdownStyles = [
'background' => $data['visual_effects']['background_color'] ?? '#ffffff',
'border' => 'none',
'box-shadow' => $data['visual_effects']['shadow'] ?? '0 8px 24px rgba(0, 0, 0, 0.12)',
'border-radius' => $data['visual_effects']['border_radius'] ?? '8px',
'padding' => '0.5rem 0',
'max-height' => $dropdownMaxHeight,
'overflow-y' => 'auto',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .dropdown-menu', $dropdownStyles);
// Hover en desktop para mostrar dropdown (sin necesidad de clic)
$css .= "\n@media (min-width: 992px) {";
$css .= "\n .navbar .dropdown:hover > .dropdown-menu {";
$css .= "\n display: block;";
$css .= "\n margin-top: 0;";
$css .= "\n }";
$css .= "\n .navbar .dropdown > .dropdown-toggle:active {";
$css .= "\n pointer-events: none;";
$css .= "\n }";
$css .= "\n}";
// Estilos de items del dropdown
$dropdownItemStyles = [
'color' => $data['visual_effects']['item_color'] ?? '#495057',
'padding' => $data['visual_effects']['item_padding'] ?? '0.625rem 1.25rem',
'transition' => 'all 0.3s ease',
'font-weight' => '500',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .dropdown-item', $dropdownItemStyles);
// Estilos hover de items del dropdown
$dropdownItemHoverStyles = [
'background-color' => $data['visual_effects']['item_hover_background'] ?? 'rgba(255, 133, 0, 0.1)',
'color' => $linkHoverColor,
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .dropdown-item:hover, .navbar .dropdown-item:focus', $dropdownItemHoverStyles);
// Estilos del brand (texto)
$brandStyles = [
'color' => ($data['media']['brand_color'] ?? '#FFFFFF') . ' !important',
'font-weight' => '700',
'font-size' => $data['media']['brand_font_size'] ?? '1.5rem',
'transition' => 'color 0.3s ease',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .navbar-brand, .navbar .roi-navbar-brand', $brandStyles);
// Estilos hover del brand
$brandHoverStyles = [
'color' => ($data['media']['brand_hover_color'] ?? '#FF8600') . ' !important',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .navbar-brand:hover, .navbar .roi-navbar-brand:hover', $brandHoverStyles);
// Estilos del logo (imagen)
$logoStyles = [
'height' => $data['media']['logo_height'] ?? '40px',
'width' => 'auto',
];
$css .= "\n" . $this->cssGenerator->generate('.navbar .roi-navbar-logo', $logoStyles);
return $css;
}
private function buildMenu(array $data): string
{
$menuLocation = $data['behavior']['menu_location'] ?? 'primary';
$enableDropdowns = $data['behavior']['enable_dropdowns'] ?? true;
$mobileBreakpoint = $data['behavior']['mobile_breakpoint'] ?? 'lg';
$ulClass = 'navbar-nav mb-2 mb-lg-0';
$args = [
'theme_location' => $menuLocation === 'custom' ? '' : $menuLocation,
'menu' => $menuLocation === 'custom' ? ($data['behavior']['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();
}
/**
* Obtiene las clases CSS de Bootstrap para visibilidad responsive
*
* Implementa tabla de decisión según especificación:
* - Desktop Y Mobile = null (visible en ambos)
* - Solo Desktop = 'd-none d-lg-block'
* - Solo Mobile = 'd-lg-none'
* - Ninguno = 'd-none' (oculto)
*
* @param bool $desktop Mostrar en desktop
* @param bool $mobile Mostrar en mobile
* @return string|null Clases CSS o null si visible en ambos
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if ($desktop && $mobile) {
return null; // Sin clases = visible siempre
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
return 'd-none';
}
public function supports(string $componentType): bool
{
return $componentType === 'navbar';
}
}
/**
* Custom Walker for Bootstrap 5 Navigation
*
* RESPONSABILIDAD: Adaptar wp_nav_menu() a Bootstrap 5
*
* CARACTERÍSTICAS:
* - Clases Bootstrap 5 (.nav-item, .nav-link, .dropdown)
* - Atributos data-bs-toggle para dropdowns
* - Soporte para current-menu-item
*/
class ROI_Bootstrap_Nav_Walker extends Walker_Nav_Menu
{
public function start_lvl(&$output, $depth = 0, $args = null)
{
$indent = str_repeat("\t", $depth);
$output .= "\n$indent<ul class=\"dropdown-menu\">\n";
}
public function start_el(&$output, $item, $depth = 0, $args = null, $id = 0)
{
$indent = ($depth) ? str_repeat("\t", $depth) : '';
$classes = empty($item->classes) ? [] : (array) $item->classes;
$classes[] = 'nav-item';
if ($args->walker->has_children) {
$classes[] = 'dropdown';
}
$class_names = join(' ', apply_filters('nav_menu_css_class', array_filter($classes), $item, $args, $depth));
$class_names = $class_names ? ' class="' . esc_attr($class_names) . '"' : '';
$id = apply_filters('nav_menu_item_id', 'menu-item-' . $item->ID, $item, $args, $depth);
$id = $id ? ' id="' . esc_attr($id) . '"' : '';
$output .= $indent . '<li' . $id . $class_names . '>';
$atts = [];
$atts['title'] = !empty($item->attr_title) ? $item->attr_title : '';
$atts['target'] = !empty($item->target) ? $item->target : '';
$atts['rel'] = !empty($item->xfn) ? $item->xfn : '';
$atts['href'] = !empty($item->url) ? $item->url : '';
if ($depth === 0) {
$atts['class'] = 'nav-link';
if ($args->walker->has_children) {
$atts['class'] .= ' dropdown-toggle';
$atts['data-bs-toggle'] = 'dropdown';
$atts['role'] = 'button';
$atts['aria-expanded'] = 'false';
}
} else {
$atts['class'] = 'dropdown-item';
}
if (in_array('current-menu-item', $classes)) {
$atts['class'] .= ' active';
}
$atts = apply_filters('nav_menu_link_attributes', $atts, $item, $args, $depth);
$attributes = '';
foreach ($atts as $attr => $value) {
if (!empty($value)) {
$value = ('href' === $attr) ? esc_url($value) : esc_attr($value);
$attributes .= ' ' . $attr . '="' . $value . '"';
}
}
$title = apply_filters('the_title', $item->title, $item->ID);
$title = apply_filters('nav_menu_item_title', $title, $item, $args, $depth);
$item_output = $args->before;
$item_output .= '<a' . $attributes . '>';
$item_output .= $args->link_before . $title . $args->link_after;
$item_output .= '</a>';
$item_output .= $args->after;
$output .= apply_filters('walker_nav_menu_start_el', $item_output, $item, $depth, $args);
}
}

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\RelatedPost\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* RelatedPostRenderer - Renderiza seccion de posts relacionados
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Related Posts
*
* CARACTERISTICAS:
* - Grid responsive de cards
* - Query dinamica de posts
* - Paginacion Bootstrap
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\RelatedPost\Infrastructure\Ui
*/
final class RelatedPostRenderer implements RendererInterface
{
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'related-post';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$typography = $data['typography'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
// Variables de colores del tema (defaults del template)
$colorNavyPrimary = $colors['section_title_color'] ?? '#0E2337';
$colorOrangePrimary = $colors['card_hover_border_color'] ?? '#FF8600';
$colorNeutral50 = '#f9fafb';
$colorNeutral100 = '#e5e7eb';
$colorNeutral600 = $colors['card_border_color'] ?? '#6b7280';
// Container - margin 3rem 0
$cssRules[] = $this->cssGenerator->generate('.related-posts', [
'margin' => '3rem 0',
]);
// Section title - color navy, font-weight 700, margin-bottom 2rem
$cssRules[] = $this->cssGenerator->generate('.related-posts h2', [
'color' => $colorNavyPrimary,
'font-weight' => '700',
'margin-bottom' => '2rem',
]);
// Card styles - cursor pointer, border, border-left 4px
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? $colorNeutral50;
$cssRules[] = ".related-posts .card {
cursor: pointer;
background: {$cardBgColor} !important;
border: 1px solid {$colorNeutral100} !important;
border-left: 4px solid {$colorNeutral600} !important;
transition: all 0.3s ease;
height: 100%;
}";
// Card hover - background change, shadow, border-left orange
$cssRules[] = ".related-posts .card:hover {
background: {$cardHoverBgColor} !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border-left-color: {$colorOrangePrimary} !important;
}";
// Card body - padding 1.5rem
$cssRules[] = $this->cssGenerator->generate('.related-posts .card-body', [
'padding' => '1.5rem !important',
]);
// Card title - color navy, font-weight 600, font-size 0.95rem
$cardTitleColor = $colors['card_title_color'] ?? $colorNavyPrimary;
$cssRules[] = ".related-posts .card-title {
color: {$cardTitleColor} !important;
font-weight: 600;
font-size: 0.95rem;
line-height: 1.4;
}";
// Link hover - title changes to orange
$cssRules[] = ".related-posts a:hover .card-title {
color: {$colorOrangePrimary} !important;
}";
// Pagination styles - matching template exactly
$cssRules[] = ".related-posts .page-link {
color: {$colorNeutral600};
border: 1px solid {$colorNeutral100};
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
}";
$cssRules[] = ".related-posts .page-link:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$colorOrangePrimary};
color: {$colorOrangePrimary};
}";
$cssRules[] = ".related-posts .page-item.active .page-link {
background-color: {$colorOrangePrimary};
border-color: {$colorOrangePrimary};
color: #ffffff;
}";
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnDesktop = $showOnDesktop === true || $showOnDesktop === '1' || $showOnDesktop === 1;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$showOnMobile = $showOnMobile === true || $showOnMobile === '1' || $showOnMobile === 1;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.related-posts { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.related-posts { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$layout = $data['layout'] ?? [];
$sectionTitle = $content['section_title'] ?? 'Descubre Mas Contenido';
$postsPerPage = (int)($content['posts_per_page'] ?? 12);
$orderby = $content['orderby'] ?? 'rand';
$order = $content['order'] ?? 'DESC';
$showPagination = $content['show_pagination'] ?? true;
$showPagination = $showPagination === true || $showPagination === '1' || $showPagination === 1;
// Layout columns (cast to string to handle boolean conversion from DB)
$colsDesktop = (string)($layout['columns_desktop'] ?? '3');
$colsTablet = (string)($layout['columns_tablet'] ?? '2');
$colsMobile = (string)($layout['columns_mobile'] ?? '1');
// Handle '1' stored as boolean true in DB
if ($colsDesktop === '1' || $colsDesktop === '') $colsDesktop = '3';
if ($colsTablet === '1' || $colsTablet === '') $colsTablet = '2';
if ($colsMobile === '1' || $colsMobile === '') $colsMobile = '1';
// Bootstrap column classes
$colClass = $this->getColumnClass($colsDesktop, $colsTablet, $colsMobile);
// Query related posts
$posts = $this->getRelatedPosts($postsPerPage, $orderby, $order);
if (empty($posts)) {
return '';
}
$containerClass = 'my-5 related-posts';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= sprintf(
'<h2 class="h3 mb-4">%s</h2>',
esc_html($sectionTitle)
);
$html .= '<div class="row g-4">';
foreach ($posts as $post) {
$html .= $this->buildCardHTML($post, $colClass);
}
$html .= '</div>';
if ($showPagination) {
$html .= $this->buildPaginationHTML($data);
}
$html .= '</div>';
// Reset post data
wp_reset_postdata();
return $html;
}
private function getColumnClass(string $desktop, string $tablet, string $mobile): string
{
$desktopCols = 12 / (int)$desktop;
$tabletCols = 12 / (int)$tablet;
$mobileCols = 12 / (int)$mobile;
// Template original usa col-md-4 (3 columnas desde tablet)
// col-{mobile} col-md-{tablet/desktop}
return sprintf('col-%d col-md-%d', $mobileCols, $desktopCols);
}
private function getRelatedPosts(int $perPage, string $orderby, string $order): array
{
$currentPostId = get_the_ID();
$args = [
'post_type' => 'post',
'posts_per_page' => $perPage,
'post__not_in' => $currentPostId ? [$currentPostId] : [],
'orderby' => $orderby,
'order' => $order,
'no_found_rows' => true,
];
$query = new \WP_Query($args);
return $query->posts;
}
private function buildCardHTML(\WP_Post $post, string $colClass): string
{
$permalink = get_permalink($post);
$title = get_the_title($post);
$html = sprintf('<div class="%s">', esc_attr($colClass));
$html .= sprintf(
'<a href="%s" class="text-decoration-none">',
esc_url($permalink)
);
$html .= '<div class="card h-100">';
$html .= '<div class="card-body d-flex align-items-center justify-content-center">';
$html .= sprintf(
'<h5 class="card-title h6 mb-0 text-center">%s</h5>',
esc_html($title)
);
$html .= '</div>';
$html .= '</div>';
$html .= '</a>';
$html .= '</div>';
return $html;
}
private function buildPaginationHTML(array $data): string
{
$content = $data['content'] ?? [];
$textFirst = $content['pagination_text_first'] ?? 'Inicio';
$textLast = $content['pagination_text_last'] ?? 'Fin';
$textMore = $content['pagination_text_more'] ?? 'Ver mas';
$html = '<nav aria-label="' . esc_attr__('Navegacion de posts relacionados', 'roi-theme') . '" class="mt-5">';
$html .= '<ul class="pagination justify-content-center">';
// First page
$html .= '<li class="page-item">';
$html .= sprintf(
'<a class="page-link" href="#" aria-label="%s">%s</a>',
esc_attr($textFirst),
esc_html($textFirst)
);
$html .= '</li>';
// Page numbers (static for now, can be enhanced with AJAX later)
for ($i = 1; $i <= 5; $i++) {
$activeClass = $i === 1 ? ' active' : '';
$ariaCurrent = $i === 1 ? ' aria-current="page"' : '';
$html .= sprintf(
'<li class="page-item%s"%s><a class="page-link" href="#">%d</a></li>',
$activeClass,
$ariaCurrent,
$i
);
}
// More link
$html .= '<li class="page-item">';
$html .= sprintf(
'<a class="page-link" href="#">%s</a>',
esc_html($textMore)
);
$html .= '</li>';
// Last page
$html .= '<li class="page-item">';
$html .= sprintf(
'<a class="page-link" href="#" aria-label="%s">%s</a>',
esc_attr($textLast),
esc_html($textLast)
);
$html .= '</li>';
$html .= '</ul>';
$html .= '</nav>';
return $html;
}
}

View File

@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\SocialShare\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* SocialShareRenderer - Renderiza botones de compartir en redes sociales
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Social Share
*
* CARACTERISTICAS:
* - 6 redes sociales: Facebook, Instagram, LinkedIn, WhatsApp, X, Email
* - Colores configurables por red
* - Toggle individual por red social
* - Estilos 100% desde BD via CSSGenerator
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar social share)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\SocialShare\Infrastructure\Ui
*/
final class SocialShareRenderer implements RendererInterface
{
private const NETWORKS = [
'facebook' => [
'field' => 'show_facebook',
'url_field' => 'facebook_url',
'icon' => 'bi-facebook',
'label' => 'Facebook',
'share_pattern' => 'https://www.facebook.com/sharer/sharer.php?u=%s',
],
'instagram' => [
'field' => 'show_instagram',
'url_field' => 'instagram_url',
'icon' => 'bi-instagram',
'label' => 'Instagram',
'share_pattern' => '', // Instagram no tiene share directo - requiere URL configurada
],
'linkedin' => [
'field' => 'show_linkedin',
'url_field' => 'linkedin_url',
'icon' => 'bi-linkedin',
'label' => 'LinkedIn',
'share_pattern' => 'https://www.linkedin.com/sharing/share-offsite/?url=%s',
],
'whatsapp' => [
'field' => 'show_whatsapp',
'url_field' => 'whatsapp_number',
'icon' => 'bi-whatsapp',
'label' => 'WhatsApp',
'share_pattern' => 'https://wa.me/?text=%s', // Compartir via WhatsApp
],
'twitter' => [
'field' => 'show_twitter',
'url_field' => 'twitter_url',
'icon' => 'bi-twitter-x',
'label' => 'X',
'share_pattern' => 'https://twitter.com/intent/tweet?url=%s&text=%s',
],
'email' => [
'field' => 'show_email',
'url_field' => 'email_address',
'icon' => 'bi-envelope',
'label' => 'Email',
'share_pattern' => 'mailto:?subject=%s&body=%s', // Compartir via Email
],
];
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === 'social-share';
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$effects = $data['visual_effects'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$buttonBorderWidth = $effects['button_border_width'] ?? '2px';
// Container styles
$cssRules[] = $this->cssGenerator->generate('.social-share-container', [
'margin-top' => $spacing['container_margin_top'] ?? '3rem',
'margin-bottom' => $spacing['container_margin_bottom'] ?? '3rem',
'padding-top' => $spacing['container_padding_top'] ?? '1.5rem',
'padding-bottom' => $spacing['container_padding_bottom'] ?? '1.5rem',
'border-top' => sprintf('%s solid %s',
$effects['border_top_width'] ?? '1px',
$colors['border_top_color'] ?? '#dee2e6'
),
]);
// Label styles
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-label', [
'font-size' => $typography['label_font_size'] ?? '1rem',
'color' => $colors['label_color'] ?? '#6c757d',
'margin-bottom' => $spacing['label_margin_bottom'] ?? '1rem',
]);
// Buttons wrapper
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-buttons', [
'display' => 'flex',
'flex-wrap' => 'wrap',
'gap' => $spacing['buttons_gap'] ?? '0.5rem',
]);
// Base button styles
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-buttons .btn', [
'padding' => $spacing['button_padding'] ?? '0.25rem 0.5rem',
'font-size' => $typography['icon_font_size'] ?? '1rem',
'border-width' => $buttonBorderWidth,
'border-radius' => $effects['button_border_radius'] ?? '0.375rem',
'transition' => "all {$transitionDuration} ease",
'background-color' => $colors['button_background'] ?? '#ffffff',
]);
// Hover effect
$cssRules[] = $this->cssGenerator->generate('.social-share-container .share-buttons .btn:hover', [
'box-shadow' => $effects['hover_box_shadow'] ?? '0 4px 12px rgba(0, 0, 0, 0.15)',
]);
// Network-specific colors
$networkColors = [
'facebook' => $colors['facebook_color'] ?? '#0d6efd',
'instagram' => $colors['instagram_color'] ?? '#dc3545',
'linkedin' => $colors['linkedin_color'] ?? '#0dcaf0',
'whatsapp' => $colors['whatsapp_color'] ?? '#198754',
'twitter' => $colors['twitter_color'] ?? '#212529',
'email' => $colors['email_color'] ?? '#6c757d',
];
foreach ($networkColors as $network => $color) {
// Outline style
$cssRules[] = $this->cssGenerator->generate(".social-share-container .btn-share-{$network}", [
'color' => $color,
'border-color' => $color,
]);
// Hover fills the button
$cssRules[] = $this->cssGenerator->generate(".social-share-container .btn-share-{$network}:hover", [
'background-color' => $color,
'color' => '#ffffff',
]);
}
// Responsive visibility (normalizar booleanos desde BD)
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnDesktop = $showOnDesktop === true || $showOnDesktop === '1' || $showOnDesktop === 1;
$showOnMobile = $visibility['show_on_mobile'] ?? true;
$showOnMobile = $showOnMobile === true || $showOnMobile === '1' || $showOnMobile === 1;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.social-share-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.social-share-container { display: none !important; }
}";
}
return implode("\n", $cssRules);
}
private function buildHTML(array $data): string
{
$content = $data['content'] ?? [];
$networks = $data['networks'] ?? [];
$labelText = $content['label_text'] ?? 'Compartir:';
$showLabel = $content['show_label'] ?? true;
$showLabel = $showLabel === true || $showLabel === '1' || $showLabel === 1;
$html = '<div class="social-share-container">';
// Label
if ($showLabel && !empty($labelText)) {
$html .= sprintf(
'<p class="share-label">%s</p>',
esc_html($labelText)
);
}
// Buttons wrapper
$html .= '<div class="share-buttons">';
// Get current post data for share URLs
$shareUrl = $this->getCurrentUrl();
$shareTitle = $this->getCurrentTitle();
foreach (self::NETWORKS as $networkKey => $networkData) {
$fieldKey = $networkData['field'];
$isEnabled = $networks[$fieldKey] ?? true;
$isEnabled = $isEnabled === true || $isEnabled === '1' || $isEnabled === 1;
if (!$isEnabled) {
continue;
}
// Obtener URL configurada para esta red
$urlFieldKey = $networkData['url_field'];
$configuredUrl = $networks[$urlFieldKey] ?? '';
$shareHref = $this->buildNetworkUrl(
$networkKey,
$configuredUrl,
$networkData['share_pattern'],
$shareUrl,
$shareTitle
);
// Si no hay URL válida usar "#" como fallback (para mantener el icono visible)
if (empty($shareHref)) {
$shareHref = '#';
}
$ariaLabel = sprintf('Compartir en %s', $networkData['label']);
$html .= sprintf(
'<a href="%s" class="btn btn-share-%s" aria-label="%s" target="_blank" rel="noopener noreferrer">
<i class="bi %s"></i>
</a>',
esc_url($shareHref),
esc_attr($networkKey),
esc_attr($ariaLabel),
esc_attr($networkData['icon'])
);
}
$html .= '</div>'; // .share-buttons
$html .= '</div>'; // .social-share-container
return $html;
}
private function getCurrentUrl(): string
{
if (is_singular()) {
return get_permalink() ?: '';
}
return home_url(add_query_arg([], $GLOBALS['wp']->request ?? ''));
}
private function getCurrentTitle(): string
{
if (is_singular()) {
return get_the_title() ?: '';
}
return wp_title('', false) ?: get_bloginfo('name');
}
/**
* Construye la URL para un botón de red social
*
* Prioridad:
* 1. URL configurada por el usuario → enlace directo al perfil
* 2. Sin URL configurada → usar patrón de compartir (si existe)
*/
private function buildNetworkUrl(
string $network,
string $configuredUrl,
string $sharePattern,
string $pageUrl,
string $pageTitle
): string {
// Si hay URL configurada, usarla directamente
if (!empty($configuredUrl)) {
return $this->formatConfiguredUrl($network, $configuredUrl);
}
// Si no hay URL configurada pero existe patrón de compartir
if (!empty($sharePattern)) {
return $this->formatShareUrl($network, $sharePattern, $pageUrl, $pageTitle);
}
return '#';
}
/**
* Formatea URL configurada según el tipo de red
*/
private function formatConfiguredUrl(string $network, string $url): string
{
switch ($network) {
case 'whatsapp':
// Para WhatsApp, el número debe ir sin el +
$number = preg_replace('/[^0-9]/', '', $url);
return "https://wa.me/{$number}";
case 'email':
// Para email, agregar mailto: si no lo tiene
if (!str_starts_with($url, 'mailto:')) {
return "mailto:{$url}";
}
return $url;
default:
return $url;
}
}
/**
* Formatea URL de compartir usando el patrón
*/
private function formatShareUrl(string $network, string $pattern, string $url, string $title): string
{
$encodedUrl = rawurlencode($url);
$encodedTitle = rawurlencode($title);
switch ($network) {
case 'twitter':
return sprintf($pattern, $encodedUrl, $encodedTitle);
case 'whatsapp':
$text = $title . ' - ' . $url;
return sprintf($pattern, rawurlencode($text));
case 'email':
return sprintf($pattern, $encodedTitle, $encodedUrl);
default:
return sprintf($pattern, $encodedUrl);
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
}

View File

@@ -0,0 +1,491 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\TableOfContents\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use DOMDocument;
use DOMXPath;
/**
* TableOfContentsRenderer - Renderiza tabla de contenido con navegacion automatica
*
* RESPONSABILIDAD: Generar HTML y CSS de la tabla de contenido
*
* CARACTERISTICAS:
* - Generacion automatica desde headings del contenido
* - ScrollSpy para navegacion activa
* - Sticky positioning configurable
* - Smooth scroll
* - Estilos 100% desde BD via CSSGenerator
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar TOC)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\TableOfContents\Infrastructure\Ui
*/
final class TableOfContentsRenderer implements RendererInterface
{
private array $headingCounter = [];
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
$tocItems = $this->generateTocItems($data);
if (empty($tocItems)) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $tocItems);
$script = $this->buildScript($data);
return sprintf("<style>%s</style>\n%s\n%s", $css, $html, $script);
}
public function supports(string $componentType): bool
{
return $componentType === 'table-of-contents';
}
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
return null;
}
if (!$desktop && $mobile) {
return 'd-lg-none';
}
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
return '';
}
private function generateTocItems(array $data): array
{
$content = $data['content'] ?? [];
$autoGenerate = $content['auto_generate'] ?? true;
if (!$autoGenerate) {
return [];
}
$headingLevelsStr = $content['heading_levels'] ?? 'h2,h3';
$headingLevels = array_map('trim', explode(',', $headingLevelsStr));
return $this->generateTocFromContent($headingLevels);
}
private function generateTocFromContent(array $headingLevels): array
{
global $post;
if (!$post || empty($post->post_content)) {
return [];
}
$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);
$tocItems = [];
$xpathQuery = implode(' | ', array_map(function($level) {
return '//' . $level;
}, $headingLevels));
$headings = $xpath->query($xpathQuery);
if ($headings->length === 0) {
return [];
}
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');
if (empty($existingId)) {
$anchor = $this->generateAnchorId($text);
$this->addIdToHeading($text, $anchor);
} else {
$anchor = $existingId;
}
$tocItems[] = [
'text' => $text,
'anchor' => $anchor,
'level' => $level
];
}
return $tocItems;
}
private function generateAnchorId(string $text): string
{
$id = strtolower($text);
$id = remove_accents($id);
$id = preg_replace('/[^a-z0-9]+/', '-', $id);
$id = trim($id, '-');
$baseId = $id;
$count = 1;
while (isset($this->headingCounter[$id])) {
$id = $baseId . '-' . $count;
$count++;
}
$this->headingCounter[$id] = true;
return $id;
}
private function addIdToHeading(string $headingText, string $anchorId): void
{
add_filter('the_content', function($content) use ($headingText, $anchorId) {
$pattern = '/<(h[2-6])([^>]*)>(\s*)' . preg_quote($headingText, '/') . '(\s*)<\/\1>/i';
$replacement = '<$1 id="' . esc_attr($anchorId) . '"$2>$3' . $headingText . '$4</$1>';
return preg_replace($pattern, $replacement, $content, 1);
}, 20);
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$effects = $data['visual_effects'] ?? [];
$behavior = $data['behavior'] ?? [];
$visibility = $data['visibility'] ?? [];
$cssRules = [];
// Container styles - Flexbox layout for proper scrolling
$cssRules[] = $this->cssGenerator->generate('.toc-container', [
'background-color' => $colors['background_color'] ?? '#ffffff',
'border' => ($effects['border_width'] ?? '1px') . ' solid ' . ($colors['border_color'] ?? '#E6E9ED'),
'border-radius' => $effects['border_radius'] ?? '8px',
'box-shadow' => $effects['box_shadow'] ?? '0 2px 8px rgba(0, 0, 0, 0.08)',
'padding' => $spacing['container_padding'] ?? '12px 16px',
'margin-bottom' => $spacing['margin_bottom'] ?? '13px',
'max-height' => $behavior['max_height'] ?? 'calc(100vh - 71px - 10px - 250px - 15px - 15px)',
'display' => 'flex',
'flex-direction' => 'column',
'overflow' => 'visible',
]);
// Sticky behavior - aplica al wrapper .sidebar-sticky de single.php
// NO al .toc-container individual (ver template líneas 817-835)
if (($behavior['is_sticky'] ?? true)) {
$cssRules[] = $this->cssGenerator->generate('.sidebar-sticky', [
'position' => 'sticky',
'top' => '85px',
'display' => 'flex',
'flex-direction' => 'column',
]);
}
// Custom scrollbar
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar', [
'width' => $spacing['scrollbar_width'] ?? '6px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-track', [
'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-thumb', [
'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
// Title styles - Color #1e3a5f = navy-primary del Design System
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-title', [
'font-size' => $typography['title_font_size'] ?? '1rem',
'font-weight' => $typography['title_font_weight'] ?? '600',
'color' => $colors['title_color'] ?? '#1e3a5f',
'padding-bottom' => $spacing['title_padding_bottom'] ?? '8px',
'margin-bottom' => $spacing['title_margin_bottom'] ?? '0.75rem',
'border-bottom' => '2px solid ' . ($colors['title_border_color'] ?? '#E6E9ED'),
'margin-top' => '0',
]);
// List styles - Scrollable area with flex
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list', [
'margin' => '0',
'padding' => '0',
'padding-right' => '0.5rem',
'list-style' => 'none',
'overflow-y' => 'auto',
'flex' => '1',
'min-height' => '0',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list li', [
'margin-bottom' => $spacing['item_margin_bottom'] ?? '0.15rem',
]);
// Link styles - Color #495057 = neutral-600 del template
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link', [
'display' => 'block',
'font-size' => $typography['link_font_size'] ?? '0.9rem',
'line-height' => $typography['link_line_height'] ?? '1.3',
'color' => $colors['link_color'] ?? '#495057',
'text-decoration' => 'none',
'padding' => $spacing['link_padding'] ?? '0.3rem 0.85rem',
'border-radius' => $effects['link_border_radius'] ?? '4px',
'border-left' => ($effects['active_border_left_width'] ?? '3px') . ' solid transparent',
'transition' => "all {$transitionDuration} ease",
]);
// Link hover - Color #1e3a5f = navy-primary del Design System
// Template: background, border-left-color, color
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link:hover', [
'color' => $colors['link_hover_color'] ?? '#1e3a5f',
'background-color' => $colors['link_hover_background'] ?? '#F9FAFB',
'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f',
]);
// Active link - Color #1e3a5f = navy-primary del Design System
// Template: font-weight: 600
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link.active', [
'color' => $colors['active_text_color'] ?? '#1e3a5f',
'background-color' => $colors['active_background_color'] ?? '#F9FAFB',
'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f',
'font-weight' => '600',
]);
// Level indentation
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-3 .toc-link', [
'padding-left' => $spacing['level_three_padding_left'] ?? '1.5rem',
'font-size' => $typography['level_three_font_size'] ?? '0.85rem',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-4 .toc-link', [
'padding-left' => $spacing['level_four_padding_left'] ?? '2rem',
'font-size' => $typography['level_four_font_size'] ?? '0.8rem',
]);
// Scrollbar for toc-list
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar', [
'width' => $spacing['scrollbar_width'] ?? '6px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-track', [
'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb', [
'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280',
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
]);
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb:hover', [
'background' => $colors['active_border_color'] ?? '#1e3a5f',
]);
// Responsive visibility
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
$showOnMobile = $visibility['show_on_mobile'] ?? false;
if (!$showOnMobile) {
$cssRules[] = "@media (max-width: 991.98px) {
.toc-container { display: none !important; }
}";
}
if (!$showOnDesktop) {
$cssRules[] = "@media (min-width: 992px) {
.toc-container { display: none !important; }
}";
}
// Responsive layout adjustments
$cssRules[] = "@media (max-width: 991px) {
.sidebar-sticky {
position: relative !important;
top: 0 !important;
}
.toc-container {
margin-bottom: 2rem;
}
.toc-container .toc-list {
max-height: 300px;
}
}";
return implode("\n", $cssRules);
}
private function buildHTML(array $data, array $tocItems): string
{
$content = $data['content'] ?? [];
$title = $content['title'] ?? 'Tabla de Contenido';
// NOTA: El sticky behavior se maneja en el wrapper .sidebar-sticky de single.php
// El TOC no debe tener la clase sidebar-sticky - está dentro del wrapper
$html = '<div class="toc-container">';
$html .= sprintf(
'<h4 class="toc-title">%s</h4>',
esc_html($title)
);
$html .= '<ol class="list-unstyled toc-list">';
foreach ($tocItems as $item) {
$text = $item['text'] ?? '';
$anchor = $item['anchor'] ?? '';
$level = $item['level'] ?? 2;
if (empty($text) || empty($anchor)) {
continue;
}
$indentClass = $level > 2 ? 'toc-level-' . $level : '';
$html .= sprintf(
'<li class="%s"><a href="#%s" class="toc-link" data-level="%d">%s</a></li>',
esc_attr($indentClass),
esc_attr($anchor),
intval($level),
esc_html($text)
);
}
$html .= '</ol>';
$html .= '</div>';
return $html;
}
private function buildScript(array $data): string
{
$content = $data['content'] ?? [];
$behavior = $data['behavior'] ?? [];
$smoothScroll = $content['smooth_scroll'] ?? true;
$scrollOffset = intval($behavior['scroll_offset'] ?? 100);
if (!$smoothScroll) {
return '';
}
$script = <<<JS
<script>
document.addEventListener('DOMContentLoaded', function() {
var tocLinks = document.querySelectorAll('.toc-link');
var offsetTop = {$scrollOffset};
tocLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var targetId = this.getAttribute('href');
var targetElement = document.querySelector(targetId);
if (targetElement) {
var elementPosition = targetElement.getBoundingClientRect().top;
var offsetPosition = elementPosition + window.pageYOffset - offsetTop;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
});
});
// ScrollSpy
var sections = [];
tocLinks.forEach(function(link) {
var id = link.getAttribute('href').substring(1);
var section = document.getElementById(id);
if (section) {
sections.push({ id: id, element: section });
}
});
function updateActiveLink() {
var scrollPosition = window.pageYOffset + offsetTop + 50;
var currentSection = '';
sections.forEach(function(section) {
if (section.element.offsetTop <= scrollPosition) {
currentSection = section.id;
}
});
tocLinks.forEach(function(link) {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + currentSection) {
link.classList.add('active');
}
});
}
window.addEventListener('scroll', updateActiveLink);
updateActiveLink();
});
</script>
JS;
return $script;
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ThemeSettings\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Public\ThemeSettings\Infrastructure\Ui\ThemeSettingsRenderer;
/**
* ThemeSettingsInjector
*
* Servicio que inyecta las configuraciones globales del tema
* en los hooks de WordPress (wp_head y wp_footer).
*
* Responsabilidades:
* - Registrar hooks de WordPress
* - Obtener configuracion de theme-settings desde BD
* - Delegar renderizado a ThemeSettingsRenderer
* - Inyectar contenido en los hooks correspondientes
*
* @package ROITheme\Public\ThemeSettings\Infrastructure\Services
*/
final class ThemeSettingsInjector
{
private const COMPONENT_NAME = 'theme-settings';
/**
* @param ComponentSettingsRepositoryInterface $repository Repositorio para leer configuraciones
* @param ThemeSettingsRenderer $renderer Renderer para generar contenido
*/
public function __construct(
private readonly ComponentSettingsRepositoryInterface $repository,
private readonly ThemeSettingsRenderer $renderer
) {}
/**
* Registra los hooks de WordPress para inyeccion
*
* @return void
*/
public function register(): void
{
// Inyectar en wp_head con prioridad alta para GA y CSS
add_action('wp_head', [$this, 'injectHeadContent'], 5);
// Inyectar en wp_footer con prioridad baja (al final)
add_action('wp_footer', [$this, 'injectFooterContent'], 99);
}
/**
* Inyecta contenido en wp_head
*
* Callback para el hook wp_head.
* Genera y muestra: Google Analytics, Custom CSS, Custom JS Header
*
* @return void
*/
public function injectHeadContent(): void
{
try {
$settings = $this->getSettings();
if (empty($settings)) {
return;
}
$content = $this->renderer->renderHeadContent($settings);
if (!empty($content)) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $content;
}
} catch (\Throwable $e) {
$this->logError('Error injecting head content', $e);
}
}
/**
* Inyecta contenido en wp_footer
*
* Callback para el hook wp_footer.
* Genera y muestra: Custom JS Footer
*
* @return void
*/
public function injectFooterContent(): void
{
try {
$settings = $this->getSettings();
if (empty($settings)) {
return;
}
$content = $this->renderer->renderFooterContent($settings);
if (!empty($content)) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $content;
}
} catch (\Throwable $e) {
$this->logError('Error injecting footer content', $e);
}
}
/**
* Obtiene las configuraciones del componente theme-settings
*
* @return array Configuraciones agrupadas o array vacio si no hay
*/
private function getSettings(): array
{
return $this->repository->getComponentSettings(self::COMPONENT_NAME);
}
/**
* Registra errores en el log de WordPress
*
* @param string $message Mensaje de error
* @param \Throwable $e Excepcion
* @return void
*/
private function logError(string $message, \Throwable $e): void
{
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'ROI Theme - ThemeSettingsInjector: %s - %s in %s:%d',
$message,
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
}
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ThemeSettings\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* ThemeSettingsRenderer
*
* Renderizador del componente Theme Settings.
* A diferencia de otros componentes, no renderiza HTML visual
* sino que genera codigo para inyectar en wp_head y wp_footer.
*
* NOTA: Este es un componente especial que NO requiere:
* - CSSGeneratorInterface (no genera CSS, solo inyecta CSS del usuario)
* - Grupo visibility (siempre esta activo, configuraciones globales)
* - Metodo getVisibilityClasses (no es un componente visual)
*
* Responsabilidades:
* - Generar script de Google Analytics
* - Generar script de Google AdSense Auto Ads
* - Generar CSS personalizado
* - Generar JavaScript para header
* - Generar JavaScript para footer
*
* @package ROITheme\Public\ThemeSettings\Infrastructure\Ui
*/
final class ThemeSettingsRenderer implements RendererInterface
{
/**
* {@inheritDoc}
*
* Para este componente, render() no se usa directamente.
* Se usan los metodos especificos: renderHeadContent() y renderFooterContent()
*/
public function render(Component $component): string
{
// Este componente no renderiza HTML visual
// Los contenidos se inyectan via hooks wp_head y wp_footer
return '';
}
/**
* {@inheritDoc}
*/
public function supports(string $componentType): bool
{
return $componentType === 'theme-settings';
}
/**
* Genera contenido para wp_head
*
* Incluye:
* - Google Analytics script (si configurado)
* - Google AdSense Auto Ads script (si configurado)
* - Custom CSS (si configurado)
* - Custom JS Header (si configurado)
*
* @param array $data Datos del componente desde BD
* @return string Contenido para wp_head
*/
public function renderHeadContent(array $data): string
{
// Validar visibilidad general
if (!$this->isEnabled($data)) {
return '';
}
$output = '';
// Google Analytics
$gaOutput = $this->renderGoogleAnalytics($data);
if (!empty($gaOutput)) {
$output .= $gaOutput . "\n";
}
// Google AdSense Auto Ads
$adsenseOutput = $this->renderAdSenseAutoAds($data);
if (!empty($adsenseOutput)) {
$output .= $adsenseOutput . "\n";
}
// Custom CSS
$cssOutput = $this->renderCustomCSS($data);
if (!empty($cssOutput)) {
$output .= $cssOutput . "\n";
}
// Custom JS Header
$jsHeaderOutput = $this->renderCustomJSHeader($data);
if (!empty($jsHeaderOutput)) {
$output .= $jsHeaderOutput . "\n";
}
return $output;
}
/**
* Genera contenido para wp_footer
*
* Incluye:
* - Custom JS Footer (si configurado)
*
* @param array $data Datos del componente desde BD
* @return string Contenido para wp_footer
*/
public function renderFooterContent(array $data): string
{
// Validar visibilidad general
if (!$this->isEnabled($data)) {
return '';
}
return $this->renderCustomJSFooter($data);
}
/**
* Verifica si el componente esta habilitado
*
* NOTA: Theme Settings es un componente de configuracion global
* que siempre esta activo. No tiene grupo visibility.
* Si el usuario no quiere GA o CSS custom, simplemente deja
* los campos vacios.
*
* @param array $data Datos del componente (no usado)
* @return bool Siempre true
*/
private function isEnabled(array $data): bool
{
// Theme Settings siempre esta activo (configuraciones globales)
// Los campos individuales se validan en sus metodos respectivos
return true;
}
/**
* Genera el script de Google Analytics
*
* @param array $data Datos del componente
* @return string Script de GA o vacio si no configurado
*/
private function renderGoogleAnalytics(array $data): string
{
$trackingId = trim($data['analytics']['ga_tracking_id'] ?? '');
if (empty($trackingId)) {
return '';
}
// Verificar si GA ya esta cargado por otro plugin
if ($this->isGoogleAnalyticsLoaded()) {
return '';
}
$anonymizeIp = ($data['analytics']['ga_anonymize_ip'] ?? true) === true;
// Detectar tipo de ID (GA4 vs Universal Analytics)
if (strpos($trackingId, 'G-') === 0) {
// Google Analytics 4
return $this->renderGA4Script($trackingId, $anonymizeIp);
} elseif (strpos($trackingId, 'UA-') === 0) {
// Universal Analytics (legacy)
return $this->renderUniversalAnalyticsScript($trackingId, $anonymizeIp);
}
return '';
}
/**
* Genera script de Google Analytics 4
*
* @param string $trackingId ID de GA4 (G-XXXXXXXXXX)
* @param bool $anonymizeIp Si anonimizar IP
* @return string Script HTML
*/
private function renderGA4Script(string $trackingId, bool $anonymizeIp): string
{
$config = $anonymizeIp ? "{ 'anonymize_ip': true }" : '{}';
return sprintf(
'<!-- Google Analytics 4 (ROI Theme) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=%1$s"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag("js", new Date());
gtag("config", "%1$s", %2$s);
</script>',
esc_attr($trackingId),
$config
);
}
/**
* Genera script de Universal Analytics (legacy)
*
* @param string $trackingId ID de UA (UA-XXXXXXXX-X)
* @param bool $anonymizeIp Si anonimizar IP
* @return string Script HTML
*/
private function renderUniversalAnalyticsScript(string $trackingId, bool $anonymizeIp): string
{
$anonymizeConfig = $anonymizeIp ? "ga('set', 'anonymizeIp', true);" : '';
return sprintf(
'<!-- Universal Analytics (ROI Theme) -->
<script>
(function(i,s,o,g,r,a,m){i["GoogleAnalyticsObject"]=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,"script","https://www.google-analytics.com/analytics.js","ga");
ga("create", "%s", "auto");
%s
ga("send", "pageview");
</script>',
esc_attr($trackingId),
$anonymizeConfig
);
}
/**
* Verifica si Google Analytics ya esta cargado
*
* @return bool True si ya esta cargado por otro plugin
*/
private function isGoogleAnalyticsLoaded(): bool
{
// Verificar plugins comunes de GA
if (function_exists('gtag')) {
return true;
}
// Verificar si MonsterInsights esta activo
if (class_exists('MonsterInsights_Lite') || class_exists('MonsterInsights')) {
return true;
}
// Verificar si Site Kit de Google esta activo
if (class_exists('Google\Site_Kit\Plugin')) {
return true;
}
return false;
}
/**
* Genera el script de Google AdSense Auto Ads
*
* @param array $data Datos del componente
* @return string Script de AdSense o vacio si no configurado
*/
private function renderAdSenseAutoAds(array $data): string
{
$publisherId = trim($data['adsense']['adsense_publisher_id'] ?? '');
$autoAdsEnabled = ($data['adsense']['adsense_auto_ads'] ?? false) === true;
// Solo mostrar si tiene publisher ID y auto ads esta activado
if (empty($publisherId) || !$autoAdsEnabled) {
return '';
}
// Validar formato del publisher ID (ca-pub-XXXXXXXXXX)
if (!preg_match('/^ca-pub-\d+$/', $publisherId)) {
return '';
}
return sprintf(
'<!-- Google AdSense Auto Ads (ROI Theme) -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=%s" crossorigin="anonymous"></script>',
esc_attr($publisherId)
);
}
/**
* Genera el CSS personalizado
*
* @param array $data Datos del componente
* @return string Bloque style o vacio si no hay CSS
*/
private function renderCustomCSS(array $data): string
{
$css = trim($data['custom_code']['custom_css'] ?? '');
if (empty($css)) {
return '';
}
return sprintf(
'<!-- Custom CSS (ROI Theme) -->
<style id="roi-theme-custom-css">
%s
</style>',
$css // No escapar CSS - usuario avanzado responsable
);
}
/**
* Genera el JavaScript personalizado para header
*
* @param array $data Datos del componente
* @return string Bloque script o vacio si no hay JS
*/
private function renderCustomJSHeader(array $data): string
{
$js = trim($data['custom_code']['custom_js_header'] ?? '');
if (empty($js)) {
return '';
}
return sprintf(
'<!-- Custom JS Header (ROI Theme) -->
<script id="roi-theme-custom-js-header">
%s
</script>',
$js // No escapar JS - usuario avanzado responsable
);
}
/**
* Genera el JavaScript personalizado para footer
*
* @param array $data Datos del componente
* @return string Bloque script o vacio si no hay JS
*/
private function renderCustomJSFooter(array $data): string
{
$js = trim($data['custom_code']['custom_js_footer'] ?? '');
if (empty($js)) {
return '';
}
return sprintf(
'<!-- Custom JS Footer (ROI Theme) -->
<script id="roi-theme-custom-js-footer">
%s
</script>',
$js // No escapar JS - usuario avanzado responsable
);
}
}

View File

@@ -0,0 +1,466 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* Class TopNotificationBarRenderer
*
* Renderizador del componente Top Notification Bar para el frontend.
*
* Responsabilidades:
* - Renderizar HTML del componente top-notification-bar
* - Delegar generación de CSS a CSSGeneratorInterface
* - Validar visibilidad (is_enabled, show_on_pages, hide_on_mobile)
* - Manejar visibilidad responsive con clases Bootstrap
* - Generar script para funcionalidad dismissible
* - Sanitizar todos los outputs
*
* NO responsable de:
* - Generar string CSS (delega a CSSGeneratorService)
* - Persistir datos (ya están en Component)
* - Lógica de negocio (está en Domain)
*
* Cumple con:
* - DIP: Recibe CSSGeneratorInterface por constructor
* - SRP: Una responsabilidad (renderizar este componente)
* - Clean Architecture: Infrastructure puede usar WordPress
*
* @package ROITheme\Public\topnotificationbar\infrastructure\ui
*/
final class TopNotificationBarRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
/**
* {@inheritDoc}
*/
public function render(Component $component): string
{
$data = $component->getData();
// Validar visibilidad general
if (!$this->isEnabled($data)) {
return '';
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
return '';
}
// Generar CSS usando CSSGeneratorService
$css = $this->generateCSS($data);
// Generar HTML
$html = $this->buildHTML($data);
// Combinar todo
return sprintf(
"<style>%s</style>\n%s",
$css,
$html
);
}
/**
* {@inheritDoc}
*/
public function supports(string $componentType): bool
{
return $componentType === 'top-notification-bar';
}
/**
* Verificar si el componente está habilitado
*
* @param array $data Datos del componente
* @return bool
*/
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
'custom' => $this->isInCustomPages($data),
default => true,
};
}
/**
* Verificar si está en páginas personalizadas
*
* @param array $data Datos del componente
* @return bool
*/
private function isInCustomPages(array $data): bool
{
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
if (empty($pageIds)) {
return false;
}
$allowedIds = array_map('trim', explode(',', $pageIds));
$currentId = (string) get_the_ID();
return in_array($currentId, $allowedIds, true);
}
/**
* Verificar si el componente fue dismissed por el usuario
*
* @param array $data Datos del componente
* @return bool
*/
private function isDismissed(array $data): bool
{
if (!$this->isDismissible($data)) {
return false;
}
$cookieName = 'roi_notification_bar_dismissed';
return isset($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] === '1';
}
/**
* Verificar si el componente es dismissible
*
* @param array $data Datos del componente
* @return bool
*/
private function isDismissible(array $data): bool
{
return ($data['behavior']['is_dismissible'] ?? false) === true;
}
/**
* Generar CSS usando CSSGeneratorService
*
* @param array $data Datos del componente
* @return string CSS generado
*/
private function generateCSS(array $data): string
{
$css = '';
// Estilos base de la barra
$baseStyles = [
'background_color' => $data['styles']['background_color'] ?? '#0E2337',
'color' => $data['styles']['text_color'] ?? '#FFFFFF',
'font_size' => $data['styles']['font_size'] ?? '0.9rem',
'padding' => $data['styles']['padding'] ?? '0.5rem 0',
'width' => '100%',
'z_index' => '1050',
];
$css .= $this->cssGenerator->generate('.top-notification-bar', $baseStyles);
// Estilos del ícono
$iconStyles = [
'color' => $data['styles']['icon_color'] ?? '#FF8600',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-icon', $iconStyles);
// Estilos de la etiqueta (label)
$labelStyles = [
'color' => $data['styles']['label_color'] ?? '#FF8600',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-label', $labelStyles);
// Estilos del enlace
$linkStyles = [
'color' => $data['styles']['link_color'] ?? '#FFFFFF',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-link', $linkStyles);
// Estilos del enlace hover
$linkHoverStyles = [
'color' => $data['styles']['link_hover_color'] ?? '#FF8600',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .notification-link:hover', $linkHoverStyles);
// Estilos del ícono personalizado
$customIconStyles = [
'width' => '24px',
'height' => '24px',
];
$css .= "\n" . $this->cssGenerator->generate('.top-notification-bar .custom-icon', $customIconStyles);
return $css;
}
/**
* Generar HTML del componente
*
* @param array $data Datos del componente
* @return string HTML generado
*/
private function buildHTML(array $data): string
{
$classes = $this->buildClasses($data);
$content = $this->buildContent($data);
return sprintf(
'<div class="%s">%s</div>',
esc_attr($classes),
$content
);
}
/**
* Construir clases CSS del componente
*
* @param array $data Datos del componente
* @return string Clases CSS
*/
private function buildClasses(array $data): string
{
return 'top-notification-bar';
}
/**
* Construir atributos data para dismissible
*
* @param array $data Datos del componente
* @return string Atributos HTML
*/
private function buildDismissAttributes(array $data): string
{
if (!$this->isDismissible($data)) {
return '';
}
$days = (int) ($data['behavior']['dismissible_cookie_days'] ?? 7);
return sprintf(' data-dismissible-days="%d"', $days);
}
/**
* Construir contenido del componente
*
* @param array $data Datos del componente
* @return string HTML del contenido
*/
private function buildContent(array $data): string
{
$html = '<div class="container">';
$html .= '<div class="d-flex align-items-center justify-content-center">';
// Ícono
$html .= $this->buildIcon($data);
// Texto del anuncio
$html .= $this->buildAnnouncementText($data);
// Enlace
$html .= $this->buildLink($data);
$html .= '</div>';
$html .= '</div>';
return $html;
}
/**
* Construir ícono del componente
*
* @param array $data Datos del componente
* @return string HTML del ícono
*/
private function buildIcon(array $data): string
{
// Siempre usar Bootstrap icon desde content.icon_class
$iconClass = $data['content']['icon_class'] ?? 'bi-megaphone-fill';
// Asegurar prefijo 'bi-'
if (strpos($iconClass, 'bi-') !== 0) {
$iconClass = 'bi-' . $iconClass;
}
return sprintf(
'<i class="bi %s notification-icon me-2"></i>',
esc_attr($iconClass)
);
}
/**
* Construir texto del anuncio
*
* @param array $data Datos del componente
* @return string HTML del texto
*/
private function buildAnnouncementText(array $data): string
{
$label = $data['content']['label_text'] ?? '';
$text = $data['content']['message_text'] ?? '';
if (empty($text)) {
return '';
}
$html = '<span>';
if (!empty($label)) {
$html .= sprintf('<strong class="notification-label">%s</strong> ', esc_html($label));
}
$html .= esc_html($text);
$html .= '</span>';
return $html;
}
/**
* Construir enlace de acción
*
* @param array $data Datos del componente
* @return string HTML del enlace
*/
private function buildLink(array $data): string
{
$linkText = $data['content']['link_text'] ?? '';
$linkUrl = $data['content']['link_url'] ?? '#';
if (empty($linkText)) {
return '';
}
return sprintf(
'<a href="%s" class="notification-link ms-2 text-decoration-underline">%s</a>',
esc_url($linkUrl),
esc_html($linkText)
);
}
/**
* Construir botón de cerrar
*
* @return string HTML del botón
*/
private function buildDismissButton(): string
{
return '<button type="button" class="btn-close btn-close-white ms-3 roi-dismiss-notification" aria-label="Cerrar"></button>';
}
/**
* Construir estilos CSS de animaciones
*
* @param array $data Datos del componente
* @return string CSS de animaciones
*/
private function buildAnimationStyles(array $data): string
{
$animationType = $data['visual_effects']['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 sprintf(
"%s\n.top-notification-bar.roi-animated.roi-%s { animation: %s; }",
$anim['keyframes'],
$animationType,
$anim['animation']
);
}
/**
* Construir script para funcionalidad dismissible
*
* @param array $data Datos del componente
* @return string JavaScript
*/
private function buildDismissScript(array $data): string
{
$days = (int) ($data['behavior']['dismissible_cookie_days'] ?? 7);
return sprintf(
'<script>
document.addEventListener("DOMContentLoaded", function() {
const dismissBtn = document.querySelector(".roi-dismiss-notification");
if (dismissBtn) {
dismissBtn.addEventListener("click", function() {
const bar = document.querySelector(".top-notification-bar");
if (bar) {
bar.style.display = "none";
}
const days = %d;
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>',
$days
);
}
/**
* Obtiene las clases CSS de Bootstrap para visibilidad responsive
*
* Implementa tabla de decisión según especificación (10.03):
* - Desktop Y Mobile = null (visible en ambos)
* - Solo Desktop = 'd-none d-lg-block'
* - Solo Mobile = 'd-lg-none'
* - Ninguno = 'd-none' (oculto)
*
* @param bool $desktop Mostrar en desktop
* @param bool $mobile Mostrar en mobile
* @return string|null Clases CSS o null si visible en ambos
*/
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
// Desktop Y Mobile = visible en ambos dispositivos
if ($desktop && $mobile) {
return null; // Sin clases = visible siempre
}
// Solo Desktop
if ($desktop && !$mobile) {
return 'd-none d-lg-block';
}
// Solo Mobile
if (!$desktop && $mobile) {
return 'd-lg-none';
}
// Ninguno = oculto completamente
return 'd-none';
}
}

View File

@@ -5,7 +5,7 @@
* Caja de llamada a la acción naranja en el sidebar
* Abre el modal de contacto al hacer clic
*
* @package APUs_Theme
* @package ROI_Theme
* @since 1.0.0
*/
?>

View File

@@ -4,7 +4,7 @@
*
* Hero section con degradado azul para single posts
*
* @package Apus_Theme
* @package ROI_Theme
*/
if (!is_single()) {
@@ -45,7 +45,7 @@ if (!is_single()) {
<?php the_author(); ?>
</span>
<?php
$reading_time = apus_get_reading_time();
$reading_time = roi_get_reading_time();
if ($reading_time) :
?>
<span class="hero-meta-separator">|</span>

View File

@@ -4,7 +4,7 @@
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
?>
@@ -12,7 +12,7 @@
<section class="no-results not-found">
<header class="page-header">
<h1 class="page-title"><?php esc_html_e( 'Nothing Found', 'apus-theme' ); ?></h1>
<h1 class="page-title"><?php esc_html_e( 'Nothing Found', 'roi-theme' ); ?></h1>
</header><!-- .page-header -->
<div class="page-content">
@@ -23,7 +23,7 @@
printf(
'<p>' . wp_kses(
/* translators: 1: link to WP admin new post page. */
__( 'Ready to publish your first post? <a href="%1$s">Get started here</a>.', 'apus-theme' ),
__( 'Ready to publish your first post? <a href="%1$s">Get started here</a>.', 'roi-theme' ),
array(
'a' => array(
'href' => array(),
@@ -37,7 +37,7 @@
?>
<!-- Search returned no results -->
<p><?php esc_html_e( 'Sorry, but nothing matched your search terms. Please try again with some different keywords.', 'apus-theme' ); ?></p>
<p><?php esc_html_e( 'Sorry, but nothing matched your search terms. Please try again with some different keywords.', 'roi-theme' ); ?></p>
<?php
get_search_form();
@@ -45,7 +45,7 @@
?>
<!-- Generic no content message -->
<p><?php esc_html_e( 'It seems we can&rsquo;t find what you&rsquo;re looking for. Perhaps searching can help.', 'apus-theme' ); ?></p>
<p><?php esc_html_e( 'It seems we can&rsquo;t find what you&rsquo;re looking for. Perhaps searching can help.', 'roi-theme' ); ?></p>
<?php
get_search_form();

View File

@@ -0,0 +1,42 @@
<?php
/**
* Template Part: Table of Contents (TOC)
*
* Genera automáticamente TOC desde los H2 del post
* HTML exacto del template original
*
* @package ROI_Theme
* @since 1.0.0
*/
// Solo mostrar TOC si estamos en single post
if (!is_single()) {
return;
}
// Obtener el contenido del post actual
global $post;
$post_content = $post->post_content;
// Aplicar filtros de WordPress al contenido
$post_content = apply_filters('the_content', $post_content);
// Buscar todos los H2 con ID en el contenido
preg_match_all('/<h2[^>]*id=["\']([^"\']*)["\'][^>]*>(.*?)<\/h2>/i', $post_content, $matches);
// Si no hay H2 con ID, no mostrar TOC
if (empty($matches[1])) {
return;
}
// Generar el TOC con el HTML del template
?>
<div class="toc-container">
<h4>Tabla de Contenido</h4>
<ol class="list-unstyled toc-list">
<?php foreach ($matches[1] as $index => $id) : ?>
<?php $title = strip_tags($matches[2][$index]); ?>
<li><a href="#<?php echo esc_attr($id); ?>"><?php echo esc_html($title); ?></a></li>
<?php endforeach; ?>
</ol>
</div>

View File

@@ -4,7 +4,7 @@
*
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
?>
@@ -36,7 +36,7 @@
// Posted by author
printf(
'<span class="byline"> %s <span class="author vcard"><a class="url fn n" href="%s">%s</a></span></span>',
esc_html__( 'by', 'apus-theme' ),
esc_html__( 'by', 'roi-theme' ),
esc_url( get_author_posts_url( get_the_author_meta( 'ID' ) ) ),
esc_html( get_the_author() )
);
@@ -45,9 +45,9 @@
if ( ! post_password_required() && ( comments_open() || get_comments_number() ) ) :
echo '<span class="comments-link">';
comments_popup_link(
esc_html__( 'Leave a comment', 'apus-theme' ),
esc_html__( '1 Comment', 'apus-theme' ),
esc_html__( '% Comments', 'apus-theme' )
esc_html__( 'Leave a comment', 'roi-theme' ),
esc_html__( '1 Comment', 'roi-theme' ),
esc_html__( '% Comments', 'roi-theme' )
);
echo '</span>';
endif;
@@ -63,11 +63,11 @@
<div class="post-thumbnail">
<?php
if ( is_singular() ) :
the_post_thumbnail( 'apus-featured-large', array( 'alt' => the_title_attribute( array( 'echo' => false ) ) ) );
the_post_thumbnail( 'roi-featured-large', array( 'alt' => the_title_attribute( array( 'echo' => false ) ) ) );
else :
?>
<a href="<?php the_permalink(); ?>" aria-hidden="true" tabindex="-1">
<?php the_post_thumbnail( 'apus-featured-medium', array( 'alt' => the_title_attribute( array( 'echo' => false ) ) ) ); ?>
<?php the_post_thumbnail( 'roi-featured-medium', array( 'alt' => the_title_attribute( array( 'echo' => false ) ) ) ); ?>
</a>
<?php
endif;
@@ -84,7 +84,7 @@
sprintf(
wp_kses(
/* translators: %s: Name of current post. Only visible to screen readers */
__( 'Continue reading<span class="screen-reader-text"> "%s"</span>', 'apus-theme' ),
__( 'Continue reading<span class="screen-reader-text"> "%s"</span>', 'roi-theme' ),
array(
'span' => array(
'class' => array(),
@@ -98,7 +98,7 @@
// Display pagination for multi-page posts
wp_link_pages(
array(
'before' => '<div class="page-links">' . esc_html__( 'Pages:', 'apus-theme' ),
'before' => '<div class="page-links">' . esc_html__( 'Pages:', 'roi-theme' ),
'after' => '</div>',
)
);
@@ -112,21 +112,21 @@
<footer class="entry-footer">
<?php
// Display categories
$categories_list = get_the_category_list( esc_html__( ', ', 'apus-theme' ) );
$categories_list = get_the_category_list( esc_html__( ', ', 'roi-theme' ) );
if ( $categories_list ) :
printf(
'<span class="cat-links">%s %s</span>',
esc_html__( 'Categories:', 'apus-theme' ),
esc_html__( 'Categories:', 'roi-theme' ),
$categories_list // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
);
endif;
// Display tags
$tags_list = get_the_tag_list( '', esc_html_x( ', ', 'list item separator', 'apus-theme' ) );
$tags_list = get_the_tag_list( '', esc_html_x( ', ', 'list item separator', 'roi-theme' ) );
if ( $tags_list ) :
printf(
'<span class="tags-links">%s %s</span>',
esc_html__( 'Tags:', 'apus-theme' ),
esc_html__( 'Tags:', 'roi-theme' ),
$tags_list // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
);
endif;
@@ -136,7 +136,7 @@
sprintf(
wp_kses(
/* translators: %s: Name of current post. Only visible to screen readers */
__( 'Edit <span class="screen-reader-text">%s</span>', 'apus-theme' ),
__( 'Edit <span class="screen-reader-text">%s</span>', 'roi-theme' ),
array(
'span' => array(
'class' => array(),

View File

@@ -4,7 +4,7 @@
*
* Aparece debajo del TOC en single posts
*
* @package Apus_Theme
* @package ROI_Theme
*/
if (!is_single()) {

View File

@@ -4,7 +4,7 @@
*
* Modal activado por botón "Let's Talk" y CTA Box Sidebar
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
?>
@@ -16,40 +16,40 @@
<div class="modal-header">
<h5 class="modal-title" id="contactModalLabel">
<i class="bi bi-envelope-fill me-2" style="color: #FF8600;"></i>
<?php esc_html_e( '¿Tienes alguna pregunta?', 'apus-theme' ); ?>
<?php esc_html_e( '¿Tienes alguna pregunta?', 'roi-theme' ); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php esc_attr_e( 'Cerrar', 'apus-theme' ); ?>"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php esc_attr_e( 'Cerrar', 'roi-theme' ); ?>"></button>
</div>
<div class="modal-body">
<p class="mb-4">
<?php esc_html_e( 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.', 'apus-theme' ); ?>
<?php esc_html_e( 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.', 'roi-theme' ); ?>
</p>
<form id="modalContactForm">
<div class="row g-3">
<div class="col-md-6">
<label for="modalFullName" class="form-label"><?php esc_html_e( 'Nombre completo', 'apus-theme' ); ?> *</label>
<label for="modalFullName" class="form-label"><?php esc_html_e( 'Nombre completo', 'roi-theme' ); ?> *</label>
<input type="text" class="form-control" id="modalFullName" name="fullName" required>
</div>
<div class="col-md-6">
<label for="modalCompany" class="form-label"><?php esc_html_e( 'Empresa', 'apus-theme' ); ?></label>
<label for="modalCompany" class="form-label"><?php esc_html_e( 'Empresa', 'roi-theme' ); ?></label>
<input type="text" class="form-control" id="modalCompany" name="company">
</div>
<div class="col-md-6">
<label for="modalWhatsapp" class="form-label"><?php esc_html_e( 'WhatsApp', 'apus-theme' ); ?> *</label>
<label for="modalWhatsapp" class="form-label"><?php esc_html_e( 'WhatsApp', 'roi-theme' ); ?> *</label>
<input type="tel" class="form-control" id="modalWhatsapp" name="whatsapp" required>
</div>
<div class="col-md-6">
<label for="modalEmail" class="form-label"><?php esc_html_e( 'Correo electrónico', 'apus-theme' ); ?> *</label>
<label for="modalEmail" class="form-label"><?php esc_html_e( 'Correo electrónico', 'roi-theme' ); ?> *</label>
<input type="email" class="form-control" id="modalEmail" name="email" required>
</div>
<div class="col-12">
<label for="modalComments" class="form-label"><?php esc_html_e( '¿En qué podemos ayudarte?', 'apus-theme' ); ?></label>
<label for="modalComments" class="form-label"><?php esc_html_e( '¿En qué podemos ayudarte?', 'roi-theme' ); ?></label>
<textarea class="form-control" id="modalComments" name="comments" rows="4"></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send-fill me-2"></i><?php esc_html_e( 'Enviar Mensaje', 'apus-theme' ); ?>
<i class="bi bi-send-fill me-2"></i><?php esc_html_e( 'Enviar Mensaje', 'roi-theme' ); ?>
</button>
</div>
<div id="modalFormMessage" class="col-12 mt-2 alert" style="display: none;"></div>

View File

@@ -0,0 +1,20 @@
<?php
/**
* Top Notification Bar Component
*
* Barra de notificaciones superior del sitio
*
* @package ROI_Theme
* @since 2.0.0
*/
?>
<div class="top-notification-bar">
<div class="container">
<div class="d-flex align-items-center justify-content-center">
<i class="bi bi-megaphone-fill me-2"></i>
<span><strong>Nuevo:</strong> Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</span>
<a href="#" class="ms-2 text-white text-decoration-underline">Ver Catálogo</a>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,538 +0,0 @@
# ANÁLISIS: Problema Crítico de Duplicación de Valores por Defecto
**Fecha:** 2025-01-13
**Severidad:** 🔴 ALTA - Problema de diseño arquitectónico
**Tipo:** Violación del principio DRY (Don't Repeat Yourself)
---
## 🔍 PROBLEMA IDENTIFICADO
El texto `"Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025."` está **duplicado en 7 ubicaciones diferentes**, lo que genera:
-**Difícil mantenimiento** - Cambiar requiere editar 7 archivos
-**Alto riesgo de errores** - Fácil olvidar actualizar un archivo
-**Inconsistencias** - Valores pueden desincronizarse
-**No hay fuente única de verdad** - Múltiples definiciones de defaults
---
## 📍 UBICACIONES DE LA DUPLICACIÓN
### 1. **admin/assets/js/admin-app.js** (línea 357)
```javascript
document.getElementById('topBarMessageText').value = topBar.message_text || 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
```
**Propósito:** Fallback en JavaScript al renderizar el formulario
**Problema:** Duplica el default que ya está en PHP
---
### 2. **admin/includes/sanitizers/class-topbar-sanitizer.php** (línea 37)
```php
public function get_defaults() {
return array(
// ...
'message_text' => 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.',
// ...
);
}
```
**Propósito:** Define defaults del sanitizer
**Problema:** ¿Por qué el sanitizer define defaults? Debería solo sanitizar.
---
### 3. **admin/includes/class-settings-manager.php** (línea 84)
```php
public function get_defaults() {
return array(
// ...
'components' => array(
'top_bar' => array(
'message_text' => 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.',
// ...
)
)
);
}
```
**Propósito:** Define defaults centralizados del Settings Manager
**Problema:** ⚠️ **DUPLICA lo que ya tiene el Sanitizer**
---
### 4. **admin/pages/main.php** (líneas 243-244, 495)
**Línea 243-244:**
```html
<textarea id="topBarMessageText"
placeholder="Ej: Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025."
required>Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</textarea>
```
**Línea 495 (preview):**
```html
<span>Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</span>
```
**Propósito:**
- Placeholder del textarea
- Valor inicial del textarea
- Texto de preview
**Problema:****TRIPLE duplicación en un solo archivo**
---
### 5. **admin/components/component-top-bar.php** (línea 190, aparece 2 veces)
```html
<textarea id="topBarMessageText"
placeholder="Ej: Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025."
required="">Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</textarea>
```
**Propósito:** Similar a main.php (placeholder + valor)
**Problema:** ¿Por qué existe este archivo si main.php ya tiene el formulario?
---
### 6. **header.php** (línea 34)
```php
'message_text' => 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.',
```
**Propósito:** Fallback en el front-end del tema
**Problema:** El front-end NO debería definir defaults, debería leerlos del Settings Manager
---
## 🏗️ ANÁLISIS ARQUITECTÓNICO
### Arquitectura ACTUAL (Problemática)
```
┌─────────────────────────────────────────────────────────────────┐
│ CAPA 1: DEFAULTS DUPLICADOS (7 lugares) │
├─────────────────────────────────────────────────────────────────┤
│ ❌ TopBar Sanitizer::get_defaults() │
│ ❌ Settings Manager::get_defaults() │
│ ❌ admin-app.js (fallbacks en render) │
│ ❌ main.php (placeholder + valor inicial + preview) │
│ ❌ component-top-bar.php (placeholder + valor) │
│ ❌ header.php (fallback front-end) │
└─────────────────────────────────────────────────────────────────┘
🔴 PROBLEMA: No hay fuente única de verdad
```
### Arquitectura CORRECTA (Propuesta)
```
┌─────────────────────────────────────────────────────────────────┐
│ ÚNICA FUENTE DE VERDAD │
├─────────────────────────────────────────────────────────────────┤
│ ✅ Settings Manager::get_defaults() SOLAMENTE │
│ - Define TODOS los defaults de TODOS los componentes │
│ - Usa constantes PHP para valores reutilizables │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ CONSUMIDORES (leen de Settings Manager) │
├─────────────────────────────────────────────────────────────────┤
│ ✅ TopBar Sanitizer → Llama Settings Manager::get_defaults() │
│ ✅ admin-app.js → AJAX lee settings (ya con defaults merged) │
│ ✅ main.php → Usa PHP para obtener defaults dinámicamente │
│ ✅ header.php → Lee Settings Manager (NO define defaults) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 🔬 RAZONES DE LA DUPLICACIÓN
### 1. **Sanitizer vs Settings Manager** (Confusión de responsabilidades)
**Problema:**
- `APUS_TopBar_Sanitizer::get_defaults()` define defaults
- `APUS_Settings_Manager::get_defaults()` TAMBIÉN define defaults
**Pregunta:** ¿Por qué el SANITIZER define defaults?
**Responsabilidades correctas:**
-**Sanitizer:** Solo SANITIZAR datos (validar, limpiar)
-**Settings Manager:** Definir defaults, leer DB, hacer merge
**Solución:**
- Eliminar `get_defaults()` del Sanitizer
- Mantener solo en Settings Manager
---
### 2. **JavaScript con Fallbacks Hardcodeados**
**Código actual (admin-app.js:357):**
```javascript
topBar.message_text || 'Accede a más de 200,000...'
```
**Problema:** JavaScript NO debería tener defaults hardcodeados.
**Solución:**
Cuando JavaScript llama a AJAX para cargar settings, el Settings Manager YA hace merge con defaults:
```php
// Settings Manager ya retorna datos con defaults merged
public function get_settings() {
$db_data = $this->db_manager->get_all_settings();
$defaults = $this->get_defaults();
return wp_parse_args($db_data, $defaults); // ← Merge automático
}
```
Por lo tanto, JavaScript NUNCA recibirá un `message_text` vacío. El fallback `|| 'Accede...'` es **innecesario**.
**Corrección:**
```javascript
// ANTES:
document.getElementById('topBarMessageText').value = topBar.message_text || 'Accede...';
// DESPUÉS:
document.getElementById('topBarMessageText').value = topBar.message_text;
// ↑ Settings Manager YA hizo merge con defaults
```
---
### 3. **HTML con Valores Hardcodeados** (main.php, component-top-bar.php)
**Código actual:**
```html
<textarea placeholder="Ej: Accede...">Accede...</textarea>
```
**Problemas:**
1. Placeholder hardcodeado
2. Valor inicial hardcodeado
3. Preview hardcodeado
**Solución:** Usar PHP para obtener defaults dinámicamente
```php
<?php
$settings_manager = new APUS_Settings_Manager();
$defaults = $settings_manager->get_defaults();
$default_message = $defaults['components']['top_bar']['message_text'];
?>
<textarea
id="topBarMessageText"
placeholder="Ej: <?php echo esc_attr($default_message); ?>"
><?php echo esc_html($default_message); ?></textarea>
```
**Preview:**
```html
<span id="topBarPreview"><?php echo esc_html($default_message); ?></span>
```
---
### 4. **component-top-bar.php vs main.php** (¿Duplicación de archivos?)
**Observación:**
- `admin/pages/main.php` contiene el formulario del Top Bar
- `admin/components/component-top-bar.php` TAMBIÉN contiene el formulario del Top Bar
**Pregunta:** ¿Por qué existen 2 archivos con el mismo formulario?
**Hipótesis:**
1. **component-top-bar.php** es un archivo PHP modular (componente)
2. **main.php** debería INCLUIR el componente, no duplicar el código
**Solución propuesta:**
```php
// main.php - Debería ser así:
<div id="topBarTab" class="tab-pane fade show active">
<?php require_once APUS_ADMIN_PANEL_PATH . 'components/component-top-bar.php'; ?>
</div>
```
Pero si component-top-bar.php es solo HTML sin lógica, entonces:
- Opción 1: Eliminar component-top-bar.php (usar solo main.php)
- Opción 2: Convertir component-top-bar.php en plantilla reutilizable
---
### 5. **header.php con Fallback** (Front-end no debería definir defaults)
**Código actual (header.php:34):**
```php
$top_bar_config = wp_parse_args($config, array(
'message_text' => 'Accede a más de 200,000...',
// ...
));
```
**Problema:** El front-end NO debería definir defaults.
**¿Por qué está esto aquí?**
Probablemente por si Settings Manager falla o no retorna datos.
**Solución correcta:**
```php
// ANTES:
$settings_manager = new APUS_Settings_Manager();
$settings = $settings_manager->get_settings();
$config = isset($settings['components']['top_bar']) ? $settings['components']['top_bar'] : array();
$top_bar_config = wp_parse_args($config, array( /* defaults hardcodeados */ ));
// DESPUÉS:
$settings_manager = new APUS_Settings_Manager();
$settings = $settings_manager->get_settings(); // ← Ya incluye defaults merged
$top_bar_config = $settings['components']['top_bar']; // ← Sin fallback necesario
```
**Razón:** `get_settings()` del Settings Manager YA hace merge con defaults.
---
## 💡 SOLUCIÓN PROPUESTA
### PASO 1: Única Fuente de Verdad (Settings Manager)
**Crear constantes para valores reutilizables:**
```php
// class-settings-manager.php
class APUS_Settings_Manager {
// Constantes de defaults
const DEFAULT_TOPBAR_MESSAGE = 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
const DEFAULT_TOPBAR_HIGHLIGHT = 'Nuevo:';
const DEFAULT_TOPBAR_LINK_TEXT = 'Ver Catálogo';
// ...
public function get_defaults() {
return array(
'version' => APUS_ADMIN_PANEL_VERSION,
'components' => array(
'top_bar' => array(
'enabled' => true,
'message_text' => self::DEFAULT_TOPBAR_MESSAGE,
'highlight_text' => self::DEFAULT_TOPBAR_HIGHLIGHT,
'link_text' => self::DEFAULT_TOPBAR_LINK_TEXT,
// ...
)
)
);
}
}
```
**Ventajas:**
- ✅ Constantes documentadas en un solo lugar
- ✅ Fácil de cambiar (1 línea en vez de 7 archivos)
- ✅ PHP autocomplete para IDEs
---
### PASO 2: Eliminar Duplicaciones
#### 2.1. Sanitizer NO debe tener `get_defaults()`
```php
// class-topbar-sanitizer.php
class APUS_TopBar_Sanitizer {
// ❌ ELIMINAR:
// public function get_defaults() { ... }
// ✅ MANTENER SOLO:
public function sanitize($data) {
// Lógica de sanitización
}
}
```
**Si el Sanitizer necesita defaults para validación:**
```php
class APUS_TopBar_Sanitizer {
private $settings_manager;
public function __construct() {
$this->settings_manager = new APUS_Settings_Manager();
}
public function sanitize($data) {
$defaults = $this->settings_manager->get_defaults()['components']['top_bar'];
// Usar $defaults si es necesario para validación
}
}
```
---
#### 2.2. JavaScript SIN Fallbacks Hardcodeados
```javascript
// admin-app.js
renderTopBar(topBar) {
// ANTES:
// document.getElementById('topBarMessageText').value = topBar.message_text || 'Accede...';
// DESPUÉS:
document.getElementById('topBarMessageText').value = topBar.message_text;
document.getElementById('topBarHighlightText').value = topBar.highlight_text;
document.getElementById('topBarLinkText').value = topBar.link_text;
// ↑ Settings Manager YA hizo merge con defaults
}
```
**Razón:** AJAX obtiene settings de `get_settings()` que ya incluye defaults.
---
#### 2.3. HTML Dinámico (usar PHP)
```php
<!-- main.php -->
<?php
$settings_manager = new APUS_Settings_Manager();
$defaults = $settings_manager->get_defaults()['components']['top_bar'];
?>
<!-- Mensaje de texto -->
<div class="mb-3">
<label class="form-label">
<i class="bi bi-chat-text me-2"></i>Mensaje Principal
</label>
<textarea
id="topBarMessageText"
class="form-control"
rows="2"
maxlength="250"
placeholder="Ej: <?php echo esc_attr($defaults['message_text']); ?>"
><?php echo esc_html($defaults['message_text']); ?></textarea>
</div>
<!-- Preview -->
<div id="topBarPreview" class="preview-top-bar">
<span><?php echo esc_html($defaults['message_text']); ?></span>
</div>
```
---
#### 2.4. Front-end SIN Fallbacks
```php
// header.php
<?php
$settings_manager = new APUS_Settings_Manager();
$settings = $settings_manager->get_settings(); // ← Ya incluye defaults merged
$top_bar_config = $settings['components']['top_bar'];
// NO hacer wp_parse_args con defaults hardcodeados
// ❌ $top_bar_config = wp_parse_args($config, array('message_text' => '...'));
?>
<!-- Renderizar Top Bar -->
<?php if ($top_bar_config['enabled']): ?>
<div class="top-notification-bar">
<span><?php echo esc_html($top_bar_config['message_text']); ?></span>
</div>
<?php endif; ?>
```
---
#### 2.5. Eliminar component-top-bar.php (¿Duplicado?)
**Investigar:**
1. ¿Se usa `component-top-bar.php` en algún lugar?
2. Si NO se usa, eliminarlo
3. Si SÍ se usa, refactorizar para que main.php lo incluya
---
## 📊 RESUMEN DE CAMBIOS
| Archivo | Acción | Razón |
|---------|--------|-------|
| `class-settings-manager.php` | ✅ **Usar constantes para defaults** | Única fuente de verdad |
| `class-topbar-sanitizer.php` | ❌ **Eliminar `get_defaults()`** | Sanitizer no debe definir defaults |
| `admin-app.js` | ❌ **Eliminar fallbacks hardcodeados** | AJAX ya retorna defaults merged |
| `main.php` | ✏️ **Usar PHP dinámico para defaults** | Leer de Settings Manager |
| `component-top-bar.php` | 🔍 **Investigar si es duplicado** | Posible eliminación |
| `header.php` | ❌ **Eliminar fallbacks hardcodeados** | get_settings() ya incluye defaults |
---
## 🎯 BENEFICIOS DE LA SOLUCIÓN
### Antes (Actual)
```
Cambiar "Accede a más de 200,000..." requiere:
├── ✏️ Editar admin-app.js
├── ✏️ Editar class-topbar-sanitizer.php
├── ✏️ Editar class-settings-manager.php
├── ✏️ Editar main.php (3 lugares)
├── ✏️ Editar component-top-bar.php (2 lugares)
└── ✏️ Editar header.php
Total: 7 archivos, ~10 líneas a cambiar
Riesgo: 🔴 ALTO (fácil olvidar un archivo)
```
### Después (Propuesto)
```
Cambiar "Accede a más de 200,000..." requiere:
└── ✏️ Editar class-settings-manager.php (1 constante)
Total: 1 archivo, 1 línea
Riesgo: 🟢 BAJO (cambio centralizado)
```
---
## 🚨 IMPACTO EN OTROS COMPONENTES
**⚠️ IMPORTANTE:** Este problema probablemente se repite en los otros 3 componentes:
1. **Navbar** - ¿Tiene duplicación similar?
2. **Let's Talk Button** - ¿Tiene duplicación similar?
3. **Hero Section** - ¿Tiene duplicación similar?
**Recomendación:** Aplicar la misma refactorización a TODOS los componentes.
---
## ✅ CHECKLIST DE IMPLEMENTACIÓN
- [ ] Crear constantes en Settings Manager
- [ ] Eliminar `get_defaults()` de TopBar Sanitizer
- [ ] Eliminar fallbacks de admin-app.js
- [ ] Convertir HTML de main.php a dinámico
- [ ] Investigar si component-top-bar.php es necesario
- [ ] Eliminar fallbacks de header.php
- [ ] Verificar que NO hay regresiones
- [ ] Aplicar solución a Navbar
- [ ] Aplicar solución a Let's Talk Button
- [ ] Aplicar solución a Hero Section
---
## 🔗 REFERENCIAS
- **Principio DRY:** Don't Repeat Yourself
- **Single Source of Truth:** Una única fuente de verdad para datos
- **Separation of Concerns:** Cada clase tiene una responsabilidad clara
---
**Última actualización:** 2025-01-13

View File

@@ -1,784 +0,0 @@
# PLAN DE ACCIÓN: CORRECCIÓN DE DEFAULTS HARDCODEADOS
**Fecha inicio:** _[Pendiente]_
**Fecha fin:** _[Pendiente]_
**Estado:** 🔴 NO INICIADO
---
## 📋 OBJETIVO
Eliminar defaults hardcodeados del código y establecer tabla `wp_apus_theme_components_defaults` como única fuente de verdad.
---
## ⏱️ TIEMPO ESTIMADO TOTAL
- **FASE 1:** 2-3 horas (Limpiar código actual)
- **FASE 2:** 1 hora (Crear tabla defaults)
- **FASE 3:** 3-4 horas (Corregir algoritmo)
- **TOTAL:** 6-8 horas
---
## 🔄 ESTADO DEL PLAN
```
FASE 1: Limpiar Código Actual [ ] 0/15 pasos completados
FASE 2: Crear Tabla Defaults [ ] 0/4 pasos completados
FASE 3: Corregir Algoritmo [ ] 0/8 pasos completados
```
**Progreso total:** 0/27 pasos (0%)
---
# FASE 1: LIMPIAR CÓDIGO ACTUAL
**Objetivo:** Eliminar código mal implementado antes de corregir algoritmo
**Duración estimada:** 2-3 horas
---
## PASO 1.1: Backup de Código Actual
**Duración:** 5 min
- [ ] Crear branch de backup: `git checkout -b backup-antes-limpieza`
- [ ] Hacer commit de estado actual: `git commit -am "backup: estado antes de limpieza de defaults"`
- [ ] Push del backup: `git push origin backup-antes-limpieza`
- [ ] Volver a main: `git checkout main`
- [ ] Crear branch de trabajo: `git checkout -b fix/limpiar-defaults-hardcodeados`
**Verificación:** Branch `backup-antes-limpieza` existe en GitHub
---
## PASO 1.2: Listar Archivos a Eliminar del Admin Panel
**Duración:** 10 min
- [ ] Ejecutar: `dir admin\assets\js\component-*.js 2>nul` (listar JS componentes)
- [ ] Ejecutar: `dir admin\assets\css\component-*.css 2>nul` (listar CSS componentes)
- [ ] Ejecutar: `dir admin\components\component-*.php 2>nul` (listar PHP componentes)
- [ ] Ejecutar: `dir admin\includes\sanitizers\class-*-sanitizer.php 2>nul` (listar sanitizers)
- [ ] Documentar lista de archivos encontrados abajo
**Archivos encontrados:**
```
JS:
-
CSS:
-
PHP Componentes:
-
Sanitizers:
-
```
---
## PASO 1.3: Eliminar Archivos JS de Componentes
**Duración:** 5 min
- [ ] Eliminar: `admin/assets/js/component-navbar.js` (si existe)
- [ ] Eliminar: `admin/assets/js/component-topbar.js` (si existe)
- [ ] Eliminar: `admin/assets/js/component-hero.js` (si existe)
- [ ] Eliminar: otros archivos `component-*.js` listados arriba
- [ ] Verificar que NO quedan archivos: `dir admin\assets\js\component-*.js 2>nul`
**Archivos eliminados:** _[Anotar aquí]_
---
## PASO 1.4: Eliminar Archivos CSS de Componentes
**Duración:** 5 min
- [ ] Eliminar: `admin/assets/css/component-navbar.css` (si existe)
- [ ] Eliminar: `admin/assets/css/component-topbar.css` (si existe)
- [ ] Eliminar: `admin/assets/css/component-hero.css` (si existe)
- [ ] Eliminar: otros archivos `component-*.css` listados arriba
- [ ] Verificar que NO quedan archivos: `dir admin\assets\css\component-*.css 2>nul`
**Archivos eliminados:** _[Anotar aquí]_
---
## PASO 1.5: Eliminar Archivos PHP de Componentes
**Duración:** 5 min
- [ ] Eliminar: `admin/components/component-navbar.php` (si existe)
- [ ] Eliminar: `admin/components/component-top-bar.php` (si existe)
- [ ] Eliminar: `admin/components/component-hero.php` (si existe)
- [ ] Eliminar: otros archivos `component-*.php` listados arriba
- [ ] Verificar que NO quedan archivos: `dir admin\components\component-*.php 2>nul`
**Archivos eliminados:** _[Anotar aquí]_
---
## PASO 1.6: Eliminar Sanitizers de Componentes
**Duración:** 5 min
- [ ] Eliminar: `admin/includes/sanitizers/class-topbar-sanitizer.php` (si existe)
- [ ] Eliminar: `admin/includes/sanitizers/class-navbar-sanitizer.php` (si existe)
- [ ] Eliminar: otros archivos `class-*-sanitizer.php` listados arriba
- [ ] Verificar que NO quedan archivos: `dir admin\includes\sanitizers\class-*-sanitizer.php 2>nul`
**Archivos eliminados:** _[Anotar aquí]_
---
## PASO 1.7: Limpiar class-admin-menu.php
**Duración:** 10 min
**Archivo:** `admin/includes/class-admin-menu.php`
- [ ] Leer el archivo completo
- [ ] Identificar líneas que encolaron CSS de componentes (wp_enqueue_style para component-*.css)
- [ ] Identificar líneas que encolaron JS de componentes (wp_enqueue_script para component-*.js)
- [ ] Eliminar todas las líneas encontradas
- [ ] Verificar que método `enqueue_assets()` solo encola archivos del core (admin-panel.css, admin-app.js)
**Líneas eliminadas:** _[Anotar números de línea]_
---
## PASO 1.8: Limpiar admin/pages/main.php (Parte 1: Analizar)
**Duración:** 15 min
**Archivo:** `admin/pages/main.php`
- [ ] Leer el archivo completo
- [ ] Buscar secciones de tabs de navegación (ej: Top Bar, Navbar, etc.)
- [ ] Buscar secciones de tab-pane con formularios de componentes
- [ ] Documentar números de línea a eliminar abajo
**Secciones encontradas:**
```
Tabs navegación:
Líneas: _____ a _____
Tab-pane Top Bar:
Líneas: _____ a _____
Tab-pane Navbar:
Líneas: _____ a _____
Otros:
Líneas: _____ a _____
```
---
## PASO 1.9: Limpiar admin/pages/main.php (Parte 2: Eliminar)
**Duración:** 10 min
**Archivo:** `admin/pages/main.php`
Usando los rangos de líneas identificados en PASO 1.8:
- [ ] Eliminar sección de tab navegación de componentes
- [ ] Eliminar sección tab-pane de Top Bar
- [ ] Eliminar sección tab-pane de Navbar
- [ ] Eliminar otras secciones documentadas arriba
- [ ] Verificar que NO quedan referencias a componentes
- [ ] Dejar SOLO estructura base del admin panel
**Verificación:** Buscar "top_bar", "navbar", "component" en el archivo - NO debe encontrar nada
---
## PASO 1.10: Limpiar admin/assets/js/admin-app.js
**Duración:** 15 min
**Archivo:** `admin/assets/js/admin-app.js`
- [ ] Leer el archivo completo
- [ ] Buscar métodos `renderTopBar()`, `renderNavbar()`, etc.
- [ ] Buscar referencias a componentes en método `collectFormData()`
- [ ] Buscar valores hardcodeados tipo: `'Accede a más de 200,000...'`
- [ ] Eliminar todos los métodos y referencias encontradas
- [ ] Verificar que NO quedan fallbacks hardcodeados (ej: `|| 'default value'`)
**Líneas eliminadas:** _[Anotar aquí]_
---
## PASO 1.11: Limpiar class-settings-manager.php (Parte 1)
**Duración:** 10 min
**Archivo:** `admin/includes/class-settings-manager.php`
- [ ] Leer método `get_defaults()` completo
- [ ] Identificar sección de defaults de componentes (top_bar, navbar, etc.)
- [ ] Documentar líneas a eliminar
**Defaults encontrados:**
```
top_bar: Líneas _____ a _____
navbar: Líneas _____ a _____
otros: Líneas _____ a _____
```
---
## PASO 1.12: Limpiar class-settings-manager.php (Parte 2)
**Duración:** 15 min
**Archivo:** `admin/includes/class-settings-manager.php`
- [ ] Eliminar método `get_defaults()` COMPLETO (se reemplazará después)
- [ ] Leer método `sanitize_settings()`
- [ ] Eliminar secciones de sanitización de componentes
- [ ] Verificar que NO quedan referencias a top_bar, navbar, etc.
**Líneas eliminadas:** _[Anotar aquí]_
---
## PASO 1.13: Limpiar Tema (header.php y otros)
**Duración:** 20 min
- [ ] Leer `header.php` completo
- [ ] Buscar código que lea de Settings Manager para componentes
- [ ] Buscar valores hardcodeados duplicados (ej: "Accede a más de 200,000...")
- [ ] Documentar qué encontraste
**Código encontrado en header.php:**
```
Líneas: _____ a _____
Descripción: _______________
```
- [ ] Revisar otros archivos del tema si es necesario
- [ ] Documentar archivos revisados
**Archivos del tema revisados:**
- [ ] header.php
- [ ] footer.php
- [ ] _______
**Decisión:** ¿Eliminar código configurable del tema o dejarlo?
_[Decidir con usuario antes de eliminar]_
---
## PASO 1.14: Limpiar Base de Datos
**Duración:** 5 min
- [ ] Conectar a base de datos (phpMyAdmin o terminal)
- [ ] Ejecutar: `SELECT * FROM wp_apus_theme_components;`
- [ ] Documentar componentes encontrados:
**Componentes en DB:**
```
component_name: ___________
component_name: ___________
```
- [ ] Ejecutar: `DELETE FROM wp_apus_theme_components;` (vaciar tabla)
- [ ] Verificar: `SELECT COUNT(*) FROM wp_apus_theme_components;` (debe ser 0)
**Registros eliminados:** _____
---
## PASO 1.15: Commit de Limpieza
**Duración:** 5 min
- [ ] Ejecutar: `git status` (ver todos los cambios)
- [ ] Ejecutar: `git add .`
- [ ] Ejecutar commit:
```bash
git commit -m "fix: eliminar implementación incorrecta de componentes
- Eliminar archivos JS/CSS/PHP de componentes mal implementados
- Limpiar class-admin-menu.php de encolamiento de componentes
- Limpiar admin/pages/main.php de secciones de componentes
- Limpiar admin-app.js de métodos y defaults hardcodeados
- Limpiar class-settings-manager.php de get_defaults() y sanitizers
- Vaciar tabla wp_apus_theme_components
Preparación para implementar arquitectura correcta con tabla defaults.
Ref: PROBLEMA-DEFAULTS-HARDCODEADOS-ALGORITMO.md"
```
- [ ] Ejecutar: `git push origin fix/limpiar-defaults-hardcodeados`
---
## ✅ CHECKLIST FASE 1 COMPLETA
- [ ] Backup creado en branch separado
- [ ] Archivos de componentes eliminados (JS, CSS, PHP, Sanitizers)
- [ ] class-admin-menu.php limpiado
- [ ] admin/pages/main.php limpiado
- [ ] admin-app.js limpiado
- [ ] class-settings-manager.php limpiado
- [ ] Tema revisado
- [ ] Base de datos vaciada
- [ ] Commit y push realizados
**Estado FASE 1:** ⬜ Pendiente | 🟡 En progreso | ✅ Completada
---
# FASE 2: CREAR TABLA DE DEFAULTS
**Objetivo:** Implementar tabla `wp_apus_theme_components_defaults` en base de datos
**Duración estimada:** 1 hora
---
## PASO 2.1: Crear Script SQL
**Duración:** 10 min
- [ ] Crear archivo: `admin/includes/migrations/create-defaults-table.sql`
- [ ] Copiar SQL de `PROBLEMA-DEFAULTS-HARDCODEADOS-ALGORITMO.md` (líneas 418-437)
- [ ] Verificar sintaxis SQL
**Contenido del archivo:**
```sql
CREATE TABLE IF NOT EXISTS wp_apus_theme_components_defaults (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
component_name VARCHAR(50) NOT NULL COMMENT 'Nombre del componente',
config_key VARCHAR(100) NOT NULL COMMENT 'Clave de configuración',
config_value TEXT NOT NULL COMMENT 'Valor por defecto extraído del tema',
data_type ENUM('string','integer','boolean','array','json') NOT NULL,
version VARCHAR(20) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_default_config (component_name, config_key),
INDEX idx_component_name (component_name),
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
---
## PASO 2.2: Ejecutar SQL en Base de Datos
**Duración:** 5 min
**Método 1: phpMyAdmin**
- [ ] Abrir phpMyAdmin
- [ ] Seleccionar base de datos del tema
- [ ] Ir a pestaña SQL
- [ ] Copiar contenido de `create-defaults-table.sql`
- [ ] Ejecutar SQL
**Método 2: Terminal/CMD**
- [ ] Conectar a MySQL/MariaDB
- [ ] Ejecutar: `USE nombre_base_datos;`
- [ ] Copiar y ejecutar SQL
**Verificación:**
- [ ] Ejecutar: `SHOW TABLES LIKE 'wp_apus_theme_components_defaults';`
- [ ] Debe retornar la tabla
---
## PASO 2.3: Verificar Estructura de Tabla
**Duración:** 5 min
- [ ] Ejecutar: `DESCRIBE wp_apus_theme_components_defaults;`
- [ ] Verificar columnas:
- [ ] id (BIGINT)
- [ ] component_name (VARCHAR 50)
- [ ] config_key (VARCHAR 100)
- [ ] config_value (TEXT)
- [ ] data_type (ENUM)
- [ ] version (VARCHAR 20)
- [ ] created_at (DATETIME)
- [ ] updated_at (DATETIME)
- [ ] Verificar índices:
- [ ] PRIMARY KEY (id)
- [ ] UNIQUE (component_name, config_key)
- [ ] INDEX (component_name)
- [ ] INDEX (config_key)
---
## PASO 2.4: Commit de Creación de Tabla
**Duración:** 5 min
- [ ] Ejecutar: `git add admin/includes/migrations/create-defaults-table.sql`
- [ ] Ejecutar commit:
```bash
git commit -m "feat(db): crear tabla wp_apus_theme_components_defaults
- Tabla para almacenar valores por defecto de componentes
- Estructura normalizada (un row por campo)
- Índices para optimizar búsquedas
- Script SQL reutilizable en create-defaults-table.sql
Ref: PROBLEMA-DEFAULTS-HARDCODEADOS-ALGORITMO.md"
```
- [ ] Ejecutar: `git push origin fix/limpiar-defaults-hardcodeados`
---
## ✅ CHECKLIST FASE 2 COMPLETA
- [ ] Script SQL creado en `admin/includes/migrations/create-defaults-table.sql`
- [ ] SQL ejecutado en base de datos
- [ ] Tabla `wp_apus_theme_components_defaults` existe
- [ ] Estructura verificada (8 columnas, 3 índices)
- [ ] Commit y push realizados
**Estado FASE 2:** ⬜ Pendiente | 🟡 En progreso | ✅ Completada
---
# FASE 3: CORREGIR ALGORITMO
**Objetivo:** Modificar archivos del algoritmo para usar tabla defaults en lugar de hardcodear valores
**Duración estimada:** 3-4 horas
---
## PASO 3.1: Modificar PASO 12 del Algoritmo (Parte 1: Analizar)
**Duración:** 15 min
**Archivo:** `_planeacion/apus-theme/admin-panel-theme/100-modularizacion-admin/00-algoritmo/12-F03-IMPLEMENTACION-IMPLEMENTAR-ADMIN-JS.md`
- [ ] Leer archivo completo
- [ ] Identificar líneas con objeto `DEFAULT_CONFIG` (aprox líneas 43-51, 169-177)
- [ ] Identificar líneas con fallbacks en método `render()` (aprox líneas 117-129)
- [ ] Identificar líneas con botón reset (aprox líneas 196-204)
- [ ] Documentar cambios necesarios
**Líneas a modificar:**
```
DEFAULT_CONFIG: Líneas _____ a _____
Fallbacks render(): Líneas _____ a _____
Botón reset: Líneas _____ a _____
```
---
## PASO 3.2: Modificar PASO 12 del Algoritmo (Parte 2: Eliminar DEFAULT_CONFIG)
**Duración:** 20 min
**Archivo:** `12-F03-IMPLEMENTACION-IMPLEMENTAR-ADMIN-JS.md`
- [ ] Eliminar sección que instruye crear objeto `DEFAULT_CONFIG`
- [ ] Eliminar ejemplo de código con `const DEFAULT_CONFIG = {...}`
- [ ] Agregar nota: "❌ NO crear objeto DEFAULT_CONFIG - Los defaults vienen de DB vía AJAX"
**Texto a agregar:**
```markdown
## ❌ IMPORTANTE: NO Crear Objeto DEFAULT_CONFIG
**PROHIBIDO crear objeto con defaults hardcodeados en JavaScript.**
Los valores por defecto vienen de la base de datos vía AJAX.
Settings Manager lee de tabla `wp_apus_theme_components_defaults`.
```
---
## PASO 3.3: Modificar PASO 12 del Algoritmo (Parte 3: Corregir Fallbacks)
**Duración:** 20 min
**Archivo:** `12-F03-IMPLEMENTACION-IMPLEMENTAR-ADMIN-JS.md`
- [ ] Modificar sección del método `render()`
- [ ] Eliminar ejemplos con fallbacks: `config.field || 'default value'`
- [ ] Reemplazar por: `config.field` (sin fallback)
- [ ] Agregar nota explicando que AJAX SIEMPRE retorna datos completos (DB + defaults merged)
**Ejemplo ANTES (INCORRECTO):**
```javascript
bgColorInput.value = config.custom_styles?.bg_color || '#000000';
```
**Ejemplo DESPUÉS (CORRECTO):**
```javascript
bgColorInput.value = config.custom_styles?.bg_color;
// NO fallback necesario - Settings Manager ya hace merge con defaults de DB
```
---
## PASO 3.4: Modificar PASO 12 del Algoritmo (Parte 4: Botón Reset)
**Duración:** 15 min
**Archivo:** `12-F03-IMPLEMENTACION-IMPLEMENTAR-ADMIN-JS.md`
- [ ] Modificar sección del botón "Reset to Defaults"
- [ ] Cambiar de `loadConfig(DEFAULT_CONFIG)` a llamada AJAX
- [ ] Agregar código para llamar endpoint que retorna defaults de DB
**Código a agregar:**
```javascript
// Botón Reset to Defaults
resetBtn.addEventListener('click', function() {
if (confirm('¿Restaurar valores por defecto?')) {
// Llamar AJAX para obtener defaults de DB
axios.get(apusAdminData.ajaxUrl, {
params: {
action: 'get_component_defaults',
component: 'component_name',
nonce: apusAdminData.nonce
}
})
.then(response => {
loadConfig(response.data);
// Guardar defaults como config personalizada
saveForm();
});
}
});
```
---
## PASO 3.5: Crear NUEVO PASO en Algoritmo (Poblar Defaults)
**Duración:** 30 min
- [ ] Crear archivo: `_planeacion/.../00-algoritmo/07B-F02-DISENO-POBLAR-DEFAULTS-DB.md`
- [ ] Ubicación: DESPUÉS de PASO 7, ANTES de PASO 8
**Contenido del archivo:**
```markdown
# PASO 7B: POBLAR TABLA DE DEFAULTS
**Prerequisito:** PASO 7 completado (código configurable documentado)
## Objetivo
Insertar valores por defecto del componente en tabla `wp_apus_theme_components_defaults`.
## 7B.1 Leer Valores Extraídos
- Abrir archivo del PASO 6: `03-DOCUMENTACION-ESTRUCTURA-DATOS.md`
- Identificar TODOS los campos con sus valores por defecto
- Valores de textos/URLs: Del código hardcodeado actual
- Valores de colores/estilos: Del CSS original del componente
## 7B.2 Generar Script SQL
Crear archivo: `[componente]/defaults-insert.sql`
Formato:
INSERT INTO wp_apus_theme_components_defaults
(component_name, config_key, config_value, data_type, version)
VALUES
('[component_name]', 'enabled', '1', 'boolean', '2.1.4'),
('[component_name]', '[field1]', '[valor]', 'string', '2.1.4'),
...
## 7B.3 Ejecutar SQL
- Conectar a base de datos
- Ejecutar script SQL
- Verificar: SELECT * FROM wp_apus_theme_components_defaults WHERE component_name='[nombre]';
## 7B.4 Verificar
- [ ] Todos los campos del PASO 6 tienen row en tabla defaults
- [ ] Valores coinciden con los extraídos del código/CSS actual
- [ ] data_type es correcto para cada campo
```
---
## PASO 3.6: Modificar PASO 14 del Algoritmo (Eliminar get_defaults)
**Duración:** 30 min
**Archivo:** `_planeacion/.../00-algoritmo/14-F04-CIERRE-GIT-COMMITS.md`
- [ ] Leer sección "14.4 Modificar Settings Manager (CRÍTICO)"
- [ ] Leer subsección "Modificación 1: Agregar Defaults (línea ~146)"
- [ ] Eliminar TODO el ejemplo del método `get_defaults()` con array hardcodeado (líneas ~88-123)
- [ ] Reemplazar por instrucciones para leer de tabla defaults
**Texto a eliminar:**
```php
public function get_defaults() {
return array(
'version' => APUS_ADMIN_PANEL_VERSION,
'components' => array(
'component_name' => array(
'enabled' => true,
// ... defaults hardcodeados
)
)
);
}
```
**Texto a agregar:**
```markdown
### Modificación: Settings Manager Lee de Tabla Defaults
**❌ NO crear método get_defaults() con array hardcodeado**
Los defaults ya están en tabla `wp_apus_theme_components_defaults` (insertados en PASO 7B).
Settings Manager debe leer de DB, NO tener defaults hardcodeados.
Ver método `get_component_config()` que hace merge automático:
1. Lee config personalizada de `wp_apus_theme_components`
2. Si no existe → Lee defaults de `wp_apus_theme_components_defaults`
```
---
## PASO 3.7: Modificar DB Manager (Agregar get_component_defaults)
**Duración:** 30 min
**Archivo:** `admin/includes/class-db-manager.php`
- [ ] Leer archivo completo
- [ ] Buscar método `get_component($component_name)`
- [ ] Copiar método y modificar para leer de tabla `_defaults`
- [ ] Agregar nuevo método
**Código a agregar:**
```php
/**
* Get component default values from defaults table
*
* @param string $component_name
* @return array
*/
public function get_component_defaults($component_name) {
global $wpdb;
$table_name = $wpdb->prefix . 'apus_theme_components_defaults';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT config_key, config_value, data_type
FROM $table_name
WHERE component_name = %s",
$component_name
),
ARRAY_A
);
if (empty($results)) {
return array();
}
// Convertir rows a array asociativo
$config = array();
foreach ($results as $row) {
$config[$row['config_key']] = $this->cast_value(
$row['config_value'],
$row['data_type']
);
}
return $config;
}
/**
* Cast value to correct type based on data_type
*
* @param mixed $value
* @param string $type
* @return mixed
*/
private function cast_value($value, $type) {
switch ($type) {
case 'boolean':
return (bool) $value;
case 'integer':
return (int) $value;
case 'array':
case 'json':
return json_decode($value, true);
default:
return $value;
}
}
```
---
## PASO 3.8: Modificar Settings Manager (get_component_config)
**Duración:** 20 min
**Archivo:** `admin/includes/class-settings-manager.php`
- [ ] Buscar método `get_component_config($component_name)`
- [ ] Modificar para leer de tabla defaults si no hay config personalizada
**Código ANTES:**
```php
public function get_component_config($component_name) {
$settings = $this->get_settings();
$defaults = $this->get_defaults(); // ← Método hardcodeado
return wp_parse_args(
$settings['components'][$component_name] ?? array(),
$defaults['components'][$component_name] ?? array()
);
}
```
**Código DESPUÉS:**
```php
public function get_component_config($component_name) {
// 1. Intentar leer config personalizada
$user_config = $this->db_manager->get_component($component_name);
if (!empty($user_config)) {
return $user_config; // Usuario ya personalizó
}
// 2. Si no hay personalización, leer defaults de tabla
$defaults = $this->db_manager->get_component_defaults($component_name);
if (!empty($defaults)) {
return $defaults; // Usar defaults de DB
}
// 3. Error: componente sin defaults
error_log("APUS Theme: No defaults found for component: {$component_name}");
return array();
}
```
---
## ✅ CHECKLIST FASE 3 COMPLETA
- [ ] PASO 12 modificado (eliminado DEFAULT_CONFIG y fallbacks)
- [ ] PASO 7B creado (poblar defaults en DB)
- [ ] PASO 14 modificado (eliminado get_defaults hardcodeado)
- [ ] DB Manager modificado (agregado get_component_defaults)
- [ ] Settings Manager modificado (lee de tabla defaults)
- [ ] Todos los cambios commiteados
**Estado FASE 3:** ⬜ Pendiente | 🟡 En progreso | ✅ Completada
---
## 🎯 RESUMEN FINAL
Una vez completadas las 3 fases:
### ✅ Lo que se logró:
1. Código actual limpiado (sin implementaciones incorrectas)
2. Tabla `wp_apus_theme_components_defaults` creada y funcionando
3. Algoritmo corregido (sin defaults hardcodeados en JS/PHP)
4. DB Manager y Settings Manager leen de tabla defaults
### 🚀 Próximos pasos:
1. Ejecutar algoritmo CORREGIDO para primer componente (ej: Navbar)
2. Pasos 1-13: Generar documentación
3. PASO 7B: Insertar defaults en DB
4. PASO 14: Implementar código real
5. PASO 15-16: Testing y cierre
---
**Última actualización:** _[Fecha]_
**Estado general:** ⬜ Pendiente | 🟡 En progreso | ✅ Completado

View File

@@ -1,670 +0,0 @@
# PROBLEMA: Defaults Hardcodeados en Algoritmo de Modularización
**Fecha:** 2025-01-13
**Estado:** 🔴 EN INVESTIGACIÓN
**Prioridad:** ALTA
---
## 📋 CONTEXTO
### Situación Actual
El tema WordPress tiene valores hardcodeados en múltiples archivos:
```
wp-content/themes/apus-theme/
├── *.php → Valores hardcodeados
├── *.html → Valores hardcodeados
├── assets/
├── css/ → Valores hardcodeados
└── js/ → Valores hardcodeados
```
### Objetivo del Sistema
El **Admin Panel** debe permitir personalizar la mayoría de valores que actualmente están hardcodeados.
### Sistema de Persistencia Disponible
**✅ Ya existe tabla personalizada:** `wp_apus_theme_components`
**Ubicación:** Base de datos WordPress
**Documentación:** Ver `ANALISIS-ESTRUCTURA-ADMIN.md`
**Estructura:**
```sql
CREATE TABLE wp_apus_theme_components (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
component_name VARCHAR(50) NOT NULL, -- 'topbar', 'navbar', 'hero', etc.
config_key VARCHAR(100) NOT NULL, -- 'message_text', 'bg_color', etc.
config_value TEXT NOT NULL, -- Valor del campo
data_type ENUM('string','integer','boolean','array','json'),
version VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_config (component_name, config_key)
)
```
---
## 🔴 PROBLEMA IDENTIFICADO
### Descripción
El algoritmo de modularización ubicado en:
```
_planeacion/apus-theme/admin-panel-theme/100-modularizacion-admin/00-algoritmo/
```
**❌ PROBLEMA CRÍTICO ENCONTRADO EN PASO 12:**
- El algoritmo instruye crear un objeto `DEFAULT_CONFIG` en JavaScript con TODOS los valores por defecto hardcodeados
- Esto viola el principio de Single Source of Truth
- Los defaults deberían venir de PHP (Settings Manager) vía AJAX, NO estar duplicados en JavaScript
### Evidencia del Problema
**Ubicación:** `00-algoritmo/12-F03-IMPLEMENTACION-IMPLEMENTAR-ADMIN-JS.md`
**Líneas 43-51 y 169-177:**
```javascript
const DEFAULT_CONFIG = {
enabled: true,
campo1: 'valor default',
custom_styles: {
background_color: '#0E2337',
// ... todos los campos del PASO 6
}
};
```
**Líneas 117-129 (método render()):**
```javascript
document.getElementById('topBarIconClass').value = config.icon_class || '';
document.getElementById('topBarShowLink').checked = config.show_link || false;
const bgColorInput = document.getElementById('topBarBgColor');
bgColorInput.value = config.custom_styles?.bg_color || '#000000'; // ← Fallback hardcodeado
```
### Por Qué es un Problema
1. **Duplicación de defaults:**
- PHP Settings Manager tiene defaults
- JavaScript TAMBIÉN tiene defaults (duplicado)
- Tabla DB puede tener defaults (triplicado si se hace seed)
2. **Violación de Single Source of Truth:**
- Cambiar un default requiere editar JavaScript Y PHP
- Alto riesgo de inconsistencias
3. **Arquitectura incorrecta:**
- JavaScript NO debería tener fallbacks porque `get_settings()` de PHP ya hace merge con defaults
- AJAX siempre retorna datos completos (DB + defaults merged)
---
## ❓ PREGUNTAS PARA INVESTIGACIÓN
### PREGUNTA 1: Ubicación del Problema ✅ RESPONDIDA
**¿En cuál(es) paso(s) del algoritmo se guardan valores en archivos JS?**
- [ ] PASO 1: Crear issue
- [ ] PASO 2: Análisis con Serena
- [ ] PASO 3: Crear estructura de documentación
- [ ] PASO 4: Documentar código real
- [ ] PASO 5: Documentar campos configurables
- [ ] PASO 6: Estructura JSON
- [ ] PASO 7: Documentar código configurable
- [ ] PASO 8: Referencia AJAX
- [ ] PASO 9: Plantilla estructura HTML
- [ ] PASO 10: Ejemplos componentes
- [ ] PASO 11: Ensamblar admin HTML
- [X] **PASO 12: Implementar admin JS** ← ❌ AQUÍ ESTÁ EL PROBLEMA
- [ ] PASO 13: CSS admin panel
- [ ] PASO 14: Git commits
- [ ] PASO 15: Testing
- [ ] PASO 16: Cerrar issue
**✅ Respuesta encontrada:**
- **PASO 12** instruye crear objeto `DEFAULT_CONFIG` en JavaScript con todos los defaults hardcodeados
- **Líneas problemáticas:** 43-51, 169-177, 117-129, 223-229
- **Archivos afectados:** `component-[nombre].js` (uno por cada componente)
---
### PREGUNTA 2: Archivos JS Afectados ✅ RESPONDIDA
**¿Qué archivos JavaScript están siendo modificados con valores hardcodeados?**
Opciones probables:
- [X] `admin/assets/js/admin-app.js` ← Fallbacks en método `render()`
- [X] `admin/assets/js/component-navbar.js` ← Si se siguió PASO 12
- [X] `admin/assets/js/component-*.js` (otros componentes) ← Si se siguió PASO 12
- [ ] Archivos JS del tema (fuera de admin)
- [ ] Otro: _______________
**✅ Respuesta encontrada:**
- **Patrón del algoritmo:** CADA componente debe tener su propio archivo `component-[nombre].js`
- **Cada archivo debe tener:** Objeto `DEFAULT_CONFIG` con todos los defaults
- **Ubicación:** `admin/assets/js/component-*.js`
- **Comprobación en código actual:** `admin-app.js:357` tiene fallback hardcodeado para Top Bar
---
### PREGUNTA 3: Tipo de Valores ✅ RESPONDIDA
**¿Qué tipo de valores por defecto se están guardando en JS?**
Opciones:
- [X] Textos (ej: "Accede a más de 200,000...")
- [X] URLs (ej: "/catalogo")
- [X] Colores (ej: "#0E2337")
- [X] Iconos (ej: "bi bi-megaphone-fill")
- [X] Configuraciones booleanas (ej: enabled: true)
- [X] **Todos los anteriores** ← CORRECTO
- [ ] Otro: _______________
**✅ Respuesta encontrada:**
- Según PASO 12 líneas 169-177, el objeto `DEFAULT_CONFIG` debe contener **TODOS** los campos del PASO 6
- Esto incluye: strings, booleans, URLs, colores (custom_styles), números, selects
- **Ejemplo real encontrado:** `admin-app.js:357` tiene `'Accede a más de 200,000...'` hardcodeado
---
### PREGUNTA 4: Propósito de los Valores en JS ✅ RESPONDIDA
**¿Para qué se usan esos valores hardcodeados en JavaScript?**
Opciones:
- [X] **Fallbacks cuando AJAX no retorna datos** ← USO PRINCIPAL
- [X] Valores iniciales al renderizar formulario
- [ ] Placeholders de campos de formulario
- [ ] Valores de preview/demo
- [ ] No estoy seguro
- [X] **Botón "Reset to Defaults"** ← USO SECUNDARIO
**✅ Respuesta encontrada:**
- **Uso 1 (líneas 117-129):** Fallbacks en método `render()``config.field || 'default'`
- **Uso 2 (líneas 196-204):** Botón reset llama `loadConfig(DEFAULT_CONFIG)`
- **Problema:** ❌ Los fallbacks son INNECESARIOS porque Settings Manager ya hace merge con defaults
---
### PREGUNTA 5: Comportamiento Esperado ✅ RESPONDIDA
**¿Cómo DEBERÍAN manejarse los valores por defecto?**
Tu visión:
- [X] Guardar en tabla `wp_apus_theme_components_defaults` (NUEVA tabla, NO la misma)
- [X] Formato: Normalizado - un INSERT por campo (Opción A)
- [X] JavaScript NUNCA debe tener defaults hardcodeados
- [X] JavaScript debe leer defaults vía AJAX desde PHP
- [X] PHP lee de tabla de defaults, NO tiene `get_defaults()` hardcodeado
**✅ Respuesta del usuario:** "opocion A, no debe ser en la msima tabla personalizada wp_apus_theme_components, debe ser en wp_apus_theme_components_defaults"
**Arquitectura definida:**
1. Algoritmo extrae valores hardcodeados → Son los defaults
2. Se insertan en tabla `wp_apus_theme_components_defaults` (un row por campo)
3. Settings Manager lee de tabla de defaults
4. JavaScript NO tiene `DEFAULT_CONFIG`
5. JavaScript lee vía AJAX desde PHP
---
### PREGUNTA 6: Comparación con Sistema Actual ✅ RESPONDIDA
**¿El componente Top Bar (que ya está implementado) tiene este problema?**
Verificación necesaria:
```javascript
// ¿Existe esto en admin-app.js?
topBar.message_text || 'Accede a más de 200,000...'
```
- [X] **SÍ - Top Bar tiene defaults hardcodeados en JS** ← CONFIRMADO
- [ ] NO - Top Bar lee defaults correctamente de PHP
- [ ] NO ESTOY SEGURO
**✅ Respuesta encontrada:**
```javascript
// admin-app.js:357
document.getElementById('topBarMessageText').value = topBar.message_text || 'Accede a más de 200,000...';
```
**Archivos con defaults de Top Bar:**
1. `admin/includes/sanitizers/class-topbar-sanitizer.php` (línea 37)
2. `admin/includes/class-settings-manager.php` (línea 84)
3. `admin/assets/js/admin-app.js` (línea 357)
4. `admin/pages/main.php` (líneas 243-244, 495)
5. `admin/components/component-top-bar.php` (línea 190, 2 veces)
6. `header.php` (línea 34)
**Total:** ❌ 7 lugares con el MISMO valor hardcodeado
---
### PREGUNTA 7: Alcance del Problema ✅ RESPONDIDA
**¿Cuántos componentes están afectados?**
- [X] **Todos los componentes futuros que se modularicen** ← PREOCUPACIÓN PRINCIPAL
**✅ Respuesta:**
- **Actual:** Código existente está MAL implementado - se debe eliminar y rehacer
- **Futuro:** TODOS los componentes que se procesen con el algoritmo PASO 12/14 tendrán el mismo problema
- **Crítico:** Si no se corrige el algoritmo PRIMERO, cada nuevo componente duplicará defaults en JS y PHP
**Acción requerida:**
1. ❌ NO usar código actual como referencia (está mal hecho)
2. ✅ Corregir algoritmo PRIMERO
3. ✅ Limpiar panel de administración (eliminar rastros de componentes mal implementados)
4. ✅ Limpiar tema (eliminar código duplicado)
5. ✅ LUEGO ejecutar algoritmo corregido para cada componente
---
### PREGUNTA 8: Dónde Debe Estar la Única Fuente de Verdad ✅ RESPONDIDA
**¿Dónde deben definirse los defaults UNA SOLA VEZ?**
Tu preferencia:
- [X] **Tabla personalizada `wp_apus_theme_components_defaults`** ← ÚNICA FUENTE DE VERDAD
- [X] Formato normalizado: un row por campo
- [X] Se pobla mediante algoritmo al procesar cada componente
- [ ] ❌ NO en `Settings Manager::get_defaults()` (eliminar método hardcodeado)
- [ ] ❌ NO en JavaScript `DEFAULT_CONFIG` (eliminar objeto hardcodeado)
**✅ Respuesta del usuario:** Nueva tabla `wp_apus_theme_components_defaults` con estructura normalizada
**Flujo correcto:**
```
Algoritmo PASO 2-4
Extrae valores hardcodeados del tema
INSERT INTO wp_apus_theme_components_defaults
Settings Manager lee de tabla
JavaScript lee vía AJAX (sin fallbacks)
```
---
## 🎯 OBJETIVO DE LA SOLUCIÓN
Una vez respondidas las preguntas, definiremos:
1. **Modificaciones al algoritmo** - Qué pasos cambiar
2. **Nueva arquitectura de defaults** - Dónde y cómo guardarlos
3. **Plan de migración** - Cómo corregir código existente
4. **Validación** - Cómo verificar que la solución funciona
---
## 📝 CÓMO EL ALGORITMO EXTRAE DEFAULTS (REVISIÓN COMPLETA)
### Flujo Documentado en el Algoritmo
**PASO 2-4: Extraer valores hardcodeados del tema actual**
- Usa Serena MCP para analizar archivos PHP/CSS/JS del tema
- Identifica valores hardcodeados (textos, URLs, colores, iconos)
- Documenta estos valores en `01-DOCUMENTACION-ANALISIS-CODIGO-REAL.md`
**PASO 6: Definir estructura JSON con defaults**
- Toma los valores extraídos en PASO 2-4
- Los define como valores por defecto en estructura JSON
- Colores se extraen del CSS: `background-color: #0E2337``custom_styles.background_color: '#0E2337'`
**PASO 14 (Líneas 88-123): Implementar defaults en Settings Manager**
```php
public function get_defaults() {
return array(
'version' => APUS_ADMIN_PANEL_VERSION,
'components' => array(
'component_name' => array(
'enabled' => true,
'field1' => 'Valor por defecto', // ← Del código hardcodeado actual
'custom_styles' => array(
'background_color' => '#0E2337', // ← Del CSS original
'text_color' => '#ffffff' // ← Del CSS original
)
)
)
);
}
```
### ❌ PROBLEMA: Defaults NO se insertan en tabla
**Lo que el algoritmo NO tiene:**
- ❌ Script de inicialización que inserte defaults en `wp_apus_theme_components`
- ❌ Paso que ejecute INSERT en la tabla al activar tema
- ❌ Migrador que convierta defaults de PHP a DB
**Lo que el algoritmo SÍ tiene:**
- ✅ Defaults en PHP (Settings Manager)
- ✅ Settings Manager hace merge: `wp_parse_args($db_data, $defaults)`
- ✅ Cuando tabla está vacía, usa defaults de PHP como fallback
### ✅ ENTENDIMIENTO CORRECTO DEL FLUJO:
**El algoritmo se ejecuta MANUALMENTE componente por componente:**
1. **Ejecutar algoritmo para "Top Bar":**
- PASO 2-4: Extrae valores hardcodeados actuales de header.php, CSS, JS
- Estos valores SON los defaults del Top Bar
- PASO 14: ❌ Los pone en Settings Manager (PHP hardcodeado)
- PASO 12: ❌ Los pone en JavaScript (DEFAULT_CONFIG hardcodeado)
- ✅ DEBERÍA: Insertarlos en tabla `wp_apus_theme_components`
2. **Ejecutar algoritmo para "Navbar":**
- PASO 2-4: Extrae valores hardcodeados actuales del navbar
- Estos valores SON los defaults del Navbar
- ✅ DEBERÍA: Insertarlos en tabla `wp_apus_theme_components`
3. **Y así con cada componente...**
### ❌ LO QUE ESTÁ MAL EN EL ALGORITMO:
**PASO 12:** Pone defaults en JavaScript
**PASO 14:** Pone defaults en PHP Settings Manager
**✅ LO QUE DEBERÍA HACER:**
Agregar un NUEVO PASO (o modificar PASO 14) que:
1. Tome los valores extraídos en PASO 2-4
2. Los inserte en `wp_apus_theme_components` con INSERT INTO
3. JavaScript NO tiene defaults hardcodeados
4. PHP lee de tabla, NO tiene `get_defaults()` hardcodeado
## 📝 NOTAS ADICIONALES
_[Espacio para el usuario agregar información adicional]_
---
---
## 📊 RESUMEN EJECUTIVO DE HALLAZGOS
### ✅ Preguntas Respondidas (8 de 8) - INVESTIGACIÓN COMPLETA
| # | Pregunta | Respuesta |
|---|----------|-----------|
| 1 | ¿Dónde está el problema? | **PASO 12 y PASO 14** del algoritmo |
| 2 | ¿Qué archivos JS afectados? | `component-*.js` (uno por componente) |
| 3 | ¿Qué tipo de valores? | **TODOS** (strings, booleans, URLs, colores, etc.) |
| 4 | ¿Para qué se usan? | Fallbacks + Botón Reset |
| 5 | ¿Cómo DEBERÍAN manejarse? | Tabla `wp_apus_theme_components_defaults` normalizada |
| 6 | ¿Top Bar tiene el problema? | **SÍ** - 7 lugares con mismo default |
| 7 | ¿Cuántos componentes afectados? | Top Bar actual + TODOS los futuros |
| 8 | ¿Única fuente de verdad? | Nueva tabla `wp_apus_theme_components_defaults` |
### 🎯 Decisión Arquitectónica Final
**ÚNICA FUENTE DE VERDAD:**
- Tabla: `wp_apus_theme_components_defaults` (nueva)
- Formato: Normalizado (un row por campo)
- Poblamiento: Vía algoritmo al procesar cada componente
- ❌ Eliminar: `DEFAULT_CONFIG` en JavaScript
- ❌ Modificar: `get_defaults()` en PHP para leer de DB
---
## 🗄️ ESTRUCTURA DE NUEVA TABLA DE DEFAULTS
### Tabla: `wp_apus_theme_components_defaults`
**Propósito:** Almacenar valores por defecto extraídos del tema mediante el algoritmo de modularización
**Características:**
- ✅ Estructura normalizada (un row por campo)
- ✅ Misma estructura que `wp_apus_theme_components` para consistencia
- ✅ Se pobla automáticamente al ejecutar algoritmo para cada componente
- ✅ Single source of truth para todos los defaults del sistema
### SQL Schema
```sql
CREATE TABLE IF NOT EXISTS wp_apus_theme_components_defaults (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
component_name VARCHAR(50) NOT NULL COMMENT 'Nombre del componente (top_bar, navbar, hero, etc.)',
config_key VARCHAR(100) NOT NULL COMMENT 'Clave de configuración (message_text, bg_color, etc.)',
config_value TEXT NOT NULL COMMENT 'Valor por defecto extraído del tema',
data_type ENUM('string','integer','boolean','array','json') NOT NULL COMMENT 'Tipo de dato del valor',
version VARCHAR(20) DEFAULT NULL COMMENT 'Versión del tema cuando se insertó el default',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Fecha de creación del registro',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Última actualización',
UNIQUE KEY unique_default_config (component_name, config_key),
INDEX idx_component_name (component_name),
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Valores por defecto de componentes del tema';
```
### Estructura Genérica de Datos
```sql
-- Estructura GENÉRICA para insertar defaults de CUALQUIER componente
-- Se puebla al ejecutar algoritmo para cada componente
INSERT INTO wp_apus_theme_components_defaults
(component_name, config_key, config_value, data_type, version)
VALUES
-- Campos booleanos
('[component_name]', 'enabled', '[1|0]', 'boolean', '[version]'),
('[component_name]', '[boolean_field]', '[1|0]', 'boolean', '[version]'),
-- Campos de texto (extraídos del código hardcodeado)
('[component_name]', '[text_field]', '[valor_extraído_del_código]', 'string', '[version]'),
-- Campos numéricos
('[component_name]', '[number_field]', '[valor_numérico]', 'integer', '[version]'),
-- Custom styles (extraídos del CSS del componente)
('[component_name]', 'custom_styles.[propiedad_css]', '[valor_del_css]', 'string', '[version]');
```
**Notas:**
- `[component_name]`: Nombre del componente (ej: 'navbar', 'hero', 'footer')
- `[config_key]`: Clave del campo según PASO 6 del algoritmo
- `[config_value]`: Valor extraído del código/CSS actual del tema
- `[data_type]`: Tipo según el campo (string, integer, boolean, array, json)
- `[version]`: Versión del tema al momento de extraer defaults
### Propósito de Cada Tabla
**`wp_apus_theme_components`** (configuraciones personalizadas)
- Se guardan cuando el usuario modifica valores en el Admin Panel
- Si existe config personalizada, se usa esta
**`wp_apus_theme_components_defaults`** (valores por defecto)
- Se pueblan al ejecutar el algoritmo para cada componente
- Se usan SOLO cuando NO existe config personalizada
- Son los valores extraídos del tema actual (hardcodeados)
**Ambas tablas tienen la MISMA estructura** - la diferencia es solo su propósito.
---
## 🔄 FLUJO DE LECTURA DE DATOS
### ¿Por qué se necesitan Settings Manager (PHP) Y JavaScript?
**NO están duplicados - tienen propósitos diferentes:**
#### Settings Manager (PHP)
**Propósito:** Para que archivos PHP del tema lean configuraciones
**Uso:**
```php
// En header.php, footer.php, etc.
$settings_manager = new APUS_Settings_Manager();
$navbar_config = $settings_manager->get_component_config('navbar');
// Usar $navbar_config en el HTML del tema
echo $navbar_config['logo_url'];
```
**Lee de:**
1. Tabla `wp_apus_theme_components` (config personalizada) - PRIORIDAD ALTA
2. Si no existe → Tabla `wp_apus_theme_components_defaults` (defaults)
#### JavaScript + AJAX
**Propósito:** Para que el Admin Panel (interfaz de administración) lea/guarde configuraciones
**Uso:**
```javascript
// En admin-app.js
// Leer configuración vía AJAX
axios.get(ajaxUrl + '?action=get_component_config&component=navbar')
.then(response => {
// Renderizar formulario con los datos
renderForm(response.data);
});
// Guardar configuración vía AJAX
axios.post(ajaxUrl, formData)
.then(response => {
// Mostrar mensaje de éxito
});
```
**Lee/Escribe vía AJAX a:**
- Endpoint PHP que usa Settings Manager
- Guarda en tabla `wp_apus_theme_components`
### Flujo Completo
```
FRONTEND (tema):
header.php → Settings Manager (PHP) → Lee de DB → Muestra en tema
ADMIN PANEL:
JavaScript → AJAX → Endpoint PHP → Settings Manager → Lee/Escribe DB → Respuesta JSON
```
**Conclusión:** Se necesitan AMBOS porque sirven a partes diferentes del sistema (frontend vs admin).
---
## 🎯 PRÓXIMOS PASOS
### ✅ INVESTIGACIÓN COMPLETA - 8/8 preguntas respondidas
**ORDEN CORRECTO DE IMPLEMENTACIÓN:**
### FASE 1: LIMPIAR CÓDIGO ACTUAL (PRIMERO)
**El código actual está MAL implementado y debe eliminarse ANTES de corregir el algoritmo**
#### 1.1. Limpiar Panel de Administración
**Eliminar completamente cualquier rastro de componentes mal implementados:**
- Eliminar archivos JS de componentes: `admin/assets/js/component-*.js`
- Eliminar archivos CSS de componentes: `admin/assets/css/component-*.css`
- Eliminar archivos PHP de componentes: `admin/components/component-*.php`
- Eliminar sanitizers de componentes: `admin/includes/sanitizers/class-*-sanitizer.php`
- Limpiar `admin/pages/main.php` de secciones de componentes
- Limpiar `admin/includes/class-admin-menu.php` de encolamiento de componentes
#### 1.2. Limpiar Tema
**Eliminar valores hardcodeados duplicados:**
- Revisar `header.php` y eliminar valores duplicados
- Revisar otros archivos del tema con valores hardcodeados
- Dejar SOLO el código original del tema (antes de modularización)
#### 1.3. Limpiar Base de Datos
**Eliminar datos de componentes mal implementados:**
- Vaciar tabla `wp_apus_theme_components` o eliminar componentes específicos
- Preparar para empezar desde cero
---
### FASE 2: CREAR TABLA DE DEFAULTS
#### 2.1. Crear Tabla `wp_apus_theme_components_defaults`
- Ejecutar SQL CREATE TABLE (ver estructura arriba)
- Verificar que tabla existe y tiene estructura correcta
---
### FASE 3: CORREGIR ALGORITMO (DESPUÉS DE LIMPIAR)
#### 3.1. PASO 12: Implementar Admin JS (CORREGIR)
**Archivo:** `00-algoritmo/12-F03-IMPLEMENTACION-IMPLEMENTAR-ADMIN-JS.md`
**Cambios:**
-**ELIMINAR:** Objeto `DEFAULT_CONFIG` (líneas 43-51, 169-177)
-**ELIMINAR:** Fallbacks en método `render()` (líneas 117-129)
-**MODIFICAR:** Botón reset debe llamar endpoint AJAX para leer defaults de DB
- ✅ JavaScript NUNCA tiene valores hardcodeados
#### 3.2. PASO 14: Settings Manager (CORREGIR)
**Archivo:** `00-algoritmo/14-F04-CIERRE-GIT-COMMITS.md`
**Cambios:**
-**ELIMINAR:** Método `get_defaults()` con array hardcodeado (líneas 88-123)
-**MODIFICAR:** DB Manager para leer de tabla `_defaults`
#### 3.3. NUEVO PASO: Poblar Tabla de Defaults
**Ubicación:** Después de PASO 7
**Contenido:**
1. Leer valores extraídos en PASO 6 (estructura JSON)
2. Generar script SQL con INSERTs para tabla `wp_apus_theme_components_defaults`
3. Ejecutar script SQL
4. Verificar que defaults están en DB
---
### FASE 4: USAR ALGORITMO CORREGIDO PARA DOCUMENTAR COMPONENTES
**El algoritmo NO implementa código - solo DOCUMENTA**
Una vez el algoritmo esté corregido:
#### 4.1. Ejecutar Algoritmo Completo (16 pasos) para UN Componente
**Ejemplo:** Navbar
**Pasos 1-13: DOCUMENTACIÓN (genera 7 archivos MD)**
- PASO 1: Crear issue en GitHub
- PASO 2-4: Analizar código actual del Navbar (Serena MCP)
- PASO 5-8: Diseñar campos configurables y estructura JSON
- PASO 9-13: Documentar cómo implementar (plantillas, ejemplos, HTML, JS, CSS)
**OUTPUT:** Carpeta `navbar/` con 7 archivos MD de documentación
#### 4.2. PASO 14: Implementar Código Real
**AQUÍ es cuando se modifica código PHP/JS/CSS del tema/admin**
⚠️ **NOTA IMPORTANTE:** El PASO 14 actual del algoritmo tiene el problema que identificamos:
- Instruye crear método `get_defaults()` con array hardcodeado en Settings Manager (líneas 88-123)
- Esto es lo que necesitamos CORREGIR en FASE 3
**Con el algoritmo CORREGIDO**, el PASO 14 debe hacer:
Usando la documentación generada en pasos 1-13:
1. Modificar PHP del tema (ej: `header.php`) según `04-IMPLEMENTACION-COMPONENTE-NAVBAR.md`
2. Agregar HTML admin en `admin/pages/main.php` según `05-IMPLEMENTACION-ADMIN-HTML-NAVBAR.md`
3. Agregar JavaScript en `admin/assets/js/admin-app.js` según `07-IMPLEMENTACION-JS-ESPECIFICO.md`
4. **MODIFICAR DB Manager:** Agregar método para insertar/leer defaults de tabla `wp_apus_theme_components_defaults`
5. **MODIFICAR Settings Manager:** Leer de tabla defaults (NO array hardcodeado)
6. **INSERTAR defaults en DB:** Ejecutar script SQL con valores extraídos en PASO 4
7. Commits por cada archivo modificado
#### 4.3. PASO 15-16: Testing y Cierre
- Testing post-implementación
- Cerrar issue en GitHub
#### 4.4. Repetir para Cada Componente
Una vez completado Navbar (pasos 1-16):
1. Ejecutar algoritmo para siguiente componente (ej: Hero)
2. Generar documentación (pasos 1-13)
3. Implementar código real (paso 14)
4. Testing y cierre (pasos 15-16)
**Componentes del tema a procesar:**
- Navbar
- Hero Section
- Footer
- etc.
---
**Última actualización:** 2025-01-13 - INVESTIGACIÓN COMPLETA - 8/8 preguntas respondidas
**Estado:** 🟢 LISTO PARA IMPLEMENTACIÓN

View File

@@ -1,511 +0,0 @@
/**
* Admin Panel Styles
*
* Estilos base para el panel de administración
*
* @package Apus_Theme
* @since 2.0.0
*/
/* ========================================
Container
======================================== */
.apus-admin-panel {
max-width: 1400px;
margin: 20px auto;
}
/* ========================================
Header
======================================== */
.apus-admin-panel h1 {
margin-bottom: 10px;
}
.apus-admin-panel .description {
color: #666;
margin-bottom: 20px;
}
/* ========================================
Tabs
======================================== */
.nav-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-tabs .nav-link {
color: #666;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.nav-tabs .nav-link:hover {
color: #0073aa;
border-bottom-color: #0073aa;
}
.nav-tabs .nav-link.active {
color: #0073aa;
font-weight: 600;
border-bottom-color: #0073aa;
background-color: transparent;
}
/* ========================================
Tab Content
======================================== */
.tab-content {
background: #fff;
padding: 20px;
border: 1px solid #ddd;
border-radius: 4px;
}
.tab-pane h3 {
margin-top: 0;
margin-bottom: 15px;
font-size: 18px;
}
.tab-pane h4 {
margin-top: 25px;
margin-bottom: 10px;
font-size: 16px;
color: #333;
}
/* ========================================
Form Sections
======================================== */
.form-section {
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.form-section:last-child {
border-bottom: none;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 5px;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="url"],
.form-group input[type="email"],
.form-group input[type="number"],
.form-group select,
.form-group textarea {
max-width: 600px;
}
.form-group .form-text {
margin-top: 5px;
font-size: 13px;
}
.form-group .form-text code {
background: #f5f5f5;
padding: 2px 5px;
border-radius: 3px;
font-size: 12px;
}
/* ========================================
Action Buttons
======================================== */
.admin-actions {
padding: 20px;
background: #f9f9f9;
border-top: 1px solid #ddd;
border-radius: 4px;
}
.admin-actions .button-primary {
font-size: 14px;
padding: 8px 20px;
height: auto;
}
.admin-actions .button-primary i {
vertical-align: middle;
}
/* ========================================
Responsive
======================================== */
@media (max-width: 782px) {
.apus-admin-panel {
margin: 10px;
}
.tab-content {
padding: 15px;
}
.form-group input[type="text"],
.form-group input[type="url"],
.form-group input[type="email"],
.form-group select,
.form-group textarea {
max-width: 100%;
}
}
/* ============================================
MEJORAS ESPECÍFICAS PARA ADMIN PANEL
============================================ */
/* Variables CSS */
:root {
--color-navy-primary: #1E3A5F;
--color-navy-light: #2C5282;
--color-navy-dark: #0E2337;
--color-orange-primary: #FF8600;
--color-orange-hover: #FF6B35;
--color-neutral-50: #F9FAFB;
--color-neutral-100: #F3F4F6;
--color-neutral-600: #6B7280;
--color-neutral-700: #4B5563;
}
/* Colores de marca como clases de utilidad */
.text-navy-primary { color: var(--color-navy-primary) !important; }
.text-navy-dark { color: var(--color-navy-dark) !important; }
.text-orange-primary { color: var(--color-orange-primary) !important; }
.text-neutral-600 { color: var(--color-neutral-600) !important; }
.text-neutral-700 { color: var(--color-neutral-700) !important; }
.bg-neutral-50 { background-color: var(--color-neutral-50) !important; }
.bg-neutral-100 { background-color: var(--color-neutral-100) !important; }
/* Tab Header mejorado */
.tab-header {
padding: 1.5rem;
background: linear-gradient(135deg, rgba(30, 58, 95, 0.03) 0%, rgba(255, 134, 0, 0.02) 100%);
border-radius: 8px;
margin-bottom: 2rem;
border-left: 4px solid var(--color-orange-primary);
}
/* Section Title con icono */
.section-title {
color: var(--color-navy-primary);
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--color-neutral-100);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-title .title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--color-orange-primary), var(--color-orange-hover));
border-radius: 8px;
color: white;
font-size: 1rem;
}
/* Form Section Cards mejorados */
.form-section.card {
border: 1px solid var(--color-neutral-100);
transition: all 0.3s ease;
}
.form-section.card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
border-color: var(--color-neutral-100);
}
/* Toggle Container mejorado */
.toggle-container {
background: var(--color-neutral-50);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--color-neutral-100);
}
/* Switch más grande */
.form-switch-lg .form-check-input {
width: 3rem;
height: 1.5rem;
cursor: pointer;
}
.form-switch-lg .form-check-input:checked {
background-color: var(--color-orange-primary);
border-color: var(--color-orange-primary);
}
.form-switch-lg .form-check-input:focus {
box-shadow: 0 0 0 0.25rem rgba(255, 134, 0, 0.25);
border-color: var(--color-orange-primary);
}
.form-switch-lg .form-check-label {
margin-left: 0.5rem;
font-weight: 500;
}
/* Input Group merge (sin borde en el medio) */
.input-group-merge .input-group-text {
border-right: 0;
background-color: var(--color-neutral-50);
}
.input-group-merge .form-control {
border-left: 0;
}
.input-group-merge .form-control:focus {
border-color: var(--color-orange-primary);
box-shadow: none;
}
/* Color Picker mejorado */
.color-picker-wrapper .form-control-color {
width: 100%;
height: 60px;
border-radius: 8px;
border: 2px solid var(--color-neutral-100);
cursor: pointer;
transition: all 0.3s ease;
}
.color-picker-wrapper .form-control-color:hover {
border-color: var(--color-orange-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 134, 0, 0.15);
}
.color-picker-wrapper .form-control-color::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker-wrapper .color-preview-text {
text-align: center;
}
.color-picker-wrapper .color-preview-text code {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
/* Alert personalizado */
.alert-info-custom {
background: linear-gradient(135deg, rgba(255, 134, 0, 0.08), rgba(255, 134, 0, 0.03));
border: 1px solid rgba(255, 134, 0, 0.2);
border-radius: 8px;
padding: 1rem;
}
.alert-info-custom .alert-heading {
color: var(--color-navy-primary);
font-weight: 700;
}
.alert-info-custom p {
color: var(--color-neutral-600);
}
/* Preview Container */
.preview-container {
position: relative;
min-height: 150px;
}
.top-bar-preview {
animation: fadeInUp 0.5s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Botones de marca */
.btn-navy-primary {
background-color: var(--color-navy-primary);
border-color: var(--color-navy-primary);
color: white;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-navy-primary:hover {
background-color: var(--color-navy-light);
border-color: var(--color-navy-light);
color: white;
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(30, 58, 95, 0.2);
}
.btn-orange-primary {
background-color: var(--color-orange-primary);
border-color: var(--color-orange-primary);
color: white;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-orange-primary:hover {
background-color: var(--color-orange-hover);
border-color: var(--color-orange-hover);
color: white;
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(255, 134, 0, 0.3);
}
/* Sticky Footer Actions */
.sticky-bottom {
position: sticky;
bottom: 0;
z-index: 10;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05);
}
/* Progress bar para textarea */
.progress {
background-color: var(--color-neutral-100);
height: 4px;
border-radius: 2px;
overflow: hidden;
}
.progress .progress-bar {
transition: width 0.3s ease;
}
.progress .progress-bar.bg-orange-primary {
background-color: var(--color-orange-primary);
}
/* Badges personalizados */
.badge.bg-neutral-100 {
background-color: var(--color-neutral-100) !important;
}
.badge.text-neutral-600 {
color: var(--color-neutral-600) !important;
}
/* Form control improvements */
.form-control-lg {
font-size: 1rem;
padding: 0.75rem 1rem;
}
.form-select-lg {
font-size: 1rem;
padding: 0.75rem 1rem;
}
/* Gap utilities */
.g-4 {
gap: 1.5rem;
}
/* Border utilities */
.border-0 {
border: 0 !important;
}
.border-neutral-100 {
border-color: var(--color-neutral-100) !important;
}
/* Shadow utilities */
.shadow-sm {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
}
/* Rounded utilities */
.rounded-3 {
border-radius: 0.5rem !important;
}
.rounded-bottom {
border-bottom-left-radius: 0.5rem !important;
border-bottom-right-radius: 0.5rem !important;
}
/* Card utilities */
.card {
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 0.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Typography utilities */
.fw-bold {
font-weight: 700 !important;
}
.fw-medium {
font-weight: 500 !important;
}
.small {
font-size: 0.875rem;
}
/* Responsive mejoras */
@media (max-width: 768px) {
.tab-header {
padding: 1rem;
}
.tab-header .d-flex {
flex-direction: column;
gap: 1rem;
}
.section-title {
font-size: 1.1rem;
}
.color-picker-wrapper .form-control-color {
height: 50px;
}
.form-switch-lg .form-check-input {
width: 2.5rem;
height: 1.25rem;
}
}

View File

@@ -1,292 +0,0 @@
/* ========================================
ADMIN PANEL - NAVBAR COMPONENT STYLES
Estilos compactos para el panel de administración
======================================== */
/* Sobrescribir estilos del contenedor tab-content */
.tab-content {
padding: 24px 12px !important;
background-color: transparent !important;
}
/* Títulos de sección compactos */
.section-title-compact {
font-size: 1rem;
font-weight: 700;
color: var(--color-navy-primary);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-neutral-100);
}
.section-title-compact i {
margin-right: 0.5rem;
}
/* Fix conflicto con WordPress: Sobreescribir max-width de .card */
body .card,
.apus-admin .card {
max-width: none !important;
}
/* Fix conflicto con WordPress: Sobreescribir estilos de checkbox para que funcionen los switches de Bootstrap */
.form-switch .form-check-input[type="checkbox"] {
width: 2em !important;
height: 1em !important;
border-radius: 2em !important;
background-color: var(--bs-form-check-bg) !important;
background-image: var(--bs-form-switch-bg) !important;
background-position: left center !important;
background-size: contain !important;
background-repeat: no-repeat !important;
margin-left: -2.5em !important;
box-shadow: none !important;
}
.form-switch .form-check-input[type="checkbox"]:checked {
background-color: #0d6efd !important;
border-color: #0d6efd !important;
background-position: right center !important;
background-size: contain !important;
background-repeat: no-repeat !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e") !important;
}
body .form-switch .form-check-input[type="checkbox"]::before,
.apus-admin .form-switch .form-check-input[type="checkbox"]::before {
content: none !important;
display: none !important;
background-image: none !important;
width: 0 !important;
height: 0 !important;
}
body .form-switch .form-check-input[type="checkbox"]::after,
.apus-admin .form-switch .form-check-input[type="checkbox"]::after {
content: none !important;
display: none !important;
}
/* Cards compactos */
#navbarTab .card {
border: 1px solid #dee2e6 !important;
border-left: 4px solid #1e3a5f !important;
border-radius: 6px !important;
box-shadow: rgba(0, 0, 0, 0.075) 0px 2px 4px 0px !important;
transition: all 0.3s ease !important;
background-color: #ffffff !important;
padding: 0 !important;
margin: 0 0 16px !important;
}
.form-section.card {
border: 2px solid #d1d5db !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
transition: all 0.3s ease;
}
.form-section.card:hover {
border-color: #FF8600 !important;
box-shadow: 0 4px 16px rgba(255, 134, 0, 0.15) !important;
}
.form-section.card .card-body.p-3 {
padding: 1.25rem !important;
}
/* Color pickers compactos */
.form-control-color-compact {
width: 100%;
height: 40px;
border-radius: 0.375rem;
border: 1px solid var(--color-neutral-100);
cursor: pointer;
transition: border-color 0.3s ease;
}
.form-control-color-compact:hover {
border-color: var(--color-orange-primary);
}
/* Form controls pequeños con mejor UX */
#navbarTab .form-control-sm,
#navbarTab .form-select-sm {
font-size: 14px !important;
padding: 6px 12px !important;
border: 1px solid rgb(222, 226, 230) !important;
border-radius: 4px !important;
color: rgb(33, 37, 41) !important;
}
.form-control-sm,
.form-select-sm {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
}
/* Labels compactos */
.form-label.small {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-neutral-700);
margin-bottom: 0.25rem;
}
/* Fix alineación vertical de labels en switches */
#navbarTab .form-check.form-switch {
display: flex !important;
align-items: center !important;
}
#navbarTab .form-switch .form-check-input {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
#navbarTab .form-switch .form-check-label,
.form-switch .form-check-label.small {
line-height: 16px !important;
padding-top: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
/* Switches más compactos */
.form-check-switch .form-check-input {
width: 2.5rem;
height: 1.25rem;
}
/* Reducir gaps globales */
.row.g-3 {
--bs-gutter-x: 1rem;
--bs-gutter-y: 1rem;
}
.row.g-2 {
--bs-gutter-x: 0.5rem;
--bs-gutter-y: 0.5rem;
}
/* Badges pequeños */
.badge {
font-size: 0.7rem;
padding: 0.2em 0.5em;
vertical-align: middle;
}
/* Tab header compacto */
.tab-header {
background: linear-gradient(135deg, #0E2337 0%, #1a3a5c 100%);
border-radius: 12px;
padding: 1.5rem;
border: 2px solid #FF8600;
box-shadow: 0 4px 12px rgba(14, 35, 55, 0.15);
}
.tab-header h3 {
font-size: 1.4rem;
margin-bottom: 0.25rem;
color: #ffffff;
font-weight: 700;
}
.tab-header p {
color: rgba(255, 255, 255, 0.85);
}
/* Header específico del Navbar Tab */
#navbarTab > .rounded {
background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%) !important;
border-left: 4px solid #FF8600 !important;
border-radius: 6px !important;
padding: 24px !important;
margin-bottom: 24px !important;
box-shadow: rgba(0, 0, 0, 0.15) 0px 8px 16px 0px !important;
}
#navbarTab > .rounded h3 {
font-size: 1.5rem !important;
margin-bottom: 0.25rem !important;
color: #ffffff !important;
font-weight: 700 !important;
}
#navbarTab > .rounded p {
color: rgba(255, 255, 255, 0.85) !important;
margin-bottom: 0 !important;
}
/* Card body padding */
#navbarTab .card-body {
padding: 1rem !important;
}
/* Progress bar pequeña */
.progress[style*="height: 3px"] {
border-radius: 2px;
background-color: var(--color-neutral-100);
}
/* Botones pequeños */
.btn-sm {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
}
/* Botón primario con estilo de marca */
.btn-primary {
background-color: var(--color-orange-primary);
border-color: var(--color-orange-primary);
font-weight: 600;
}
.btn-primary:hover {
background-color: var(--color-orange-hover);
border-color: var(--color-orange-hover);
}
/* Clases de utilidad de colores */
.text-navy-primary {
color: var(--color-navy-primary);
}
.text-orange-primary {
color: var(--color-orange-primary);
}
.text-neutral-600 {
color: var(--color-neutral-600);
}
.border-neutral-200 {
border-color: #dee2e6 !important;
}
/* Responsive: Mobile First */
@media (max-width: 991px) {
.tab-header {
padding: 0.75rem;
}
.tab-header h3 {
font-size: 1.1rem;
}
.form-section.card .card-body.p-3 {
padding: 0.75rem !important;
}
}
@media (max-width: 576px) {
.tab-header .d-flex {
flex-direction: column;
align-items: flex-start !important;
}
.tab-header button {
width: 100%;
margin-top: 0.5rem;
}
}

View File

@@ -1,471 +0,0 @@
/**
* Theme Options Admin Styles
*
* @package Apus_Theme
* @since 1.0.0
*/
/* Main Container */
.apus-theme-options {
margin: 20px 20px 0 0;
}
/* Header */
.apus-options-header {
background: #fff;
border: 1px solid #c3c4c7;
padding: 20px;
margin: 20px 0;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 1px rgba(0,0,0,.04);
}
.apus-options-logo h2 {
margin: 0;
font-size: 24px;
color: #1d2327;
display: inline-block;
}
.apus-options-logo .version {
background: #2271b1;
color: #fff;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
margin-left: 10px;
}
.apus-options-actions {
display: flex;
gap: 10px;
}
.apus-options-actions .button .dashicons {
margin-top: 3px;
margin-right: 3px;
}
/* Form */
.apus-options-form {
background: #fff;
border: 1px solid #c3c4c7;
box-shadow: 0 1px 1px rgba(0,0,0,.04);
}
/* Tabs Container */
.apus-options-container {
display: flex;
min-height: 600px;
}
/* Tabs Navigation */
.apus-tabs-nav {
width: 200px;
background: #f6f7f7;
border-right: 1px solid #c3c4c7;
}
.apus-tabs-nav ul {
margin: 0;
padding: 0;
list-style: none;
}
.apus-tabs-nav li {
margin: 0;
padding: 0;
border-bottom: 1px solid #c3c4c7;
}
.apus-tabs-nav li:first-child {
border-top: 1px solid #c3c4c7;
}
.apus-tabs-nav a {
display: block;
padding: 15px 20px;
color: #50575e;
text-decoration: none;
transition: all 0.2s;
position: relative;
}
.apus-tabs-nav a .dashicons {
margin-right: 8px;
color: #787c82;
}
.apus-tabs-nav a:hover {
background: #fff;
color: #2271b1;
}
.apus-tabs-nav a:hover .dashicons {
color: #2271b1;
}
.apus-tabs-nav li.active a {
background: #fff;
color: #2271b1;
font-weight: 600;
border-left: 3px solid #2271b1;
padding-left: 17px;
}
.apus-tabs-nav li.active a .dashicons {
color: #2271b1;
}
/* Tabs Content */
.apus-tabs-content {
flex: 1;
padding: 30px;
}
.apus-tab-pane {
display: none;
}
.apus-tab-pane.active {
display: block;
}
.apus-tab-pane h2 {
margin: 0 0 10px 0;
font-size: 23px;
font-weight: 400;
line-height: 1.3;
}
.apus-tab-pane > p.description {
margin: 0 0 20px 0;
color: #646970;
}
.apus-tab-pane h3 {
margin: 30px 0 0 0;
padding: 15px 0 10px 0;
border-top: 1px solid #dcdcde;
font-size: 18px;
}
/* Form Table */
.apus-tab-pane .form-table {
margin-top: 20px;
}
.apus-tab-pane .form-table th {
padding: 20px 10px 20px 0;
width: 200px;
}
.apus-tab-pane .form-table td {
padding: 15px 10px;
}
/* Toggle Switch */
.apus-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.apus-switch input {
opacity: 0;
width: 0;
height: 0;
}
.apus-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.apus-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .apus-slider {
background-color: #2271b1;
}
input:focus + .apus-slider {
box-shadow: 0 0 1px #2271b1;
}
input:checked + .apus-slider:before {
transform: translateX(26px);
}
/* Image Upload */
.apus-image-upload {
max-width: 600px;
}
.apus-image-preview {
margin-bottom: 10px;
border: 1px solid #c3c4c7;
background: #f6f7f7;
padding: 10px;
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.apus-image-preview:empty {
display: none;
}
.apus-preview-image {
max-width: 100%;
height: auto;
display: block;
}
.apus-upload-image,
.apus-remove-image {
margin-right: 10px;
}
/* Submit Button */
.apus-options-form .submit {
margin: 0;
padding: 20px 30px;
border-top: 1px solid #c3c4c7;
background: #f6f7f7;
}
/* Modal */
.apus-modal {
display: none;
position: fixed;
z-index: 100000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.5);
}
.apus-modal-content {
background-color: #fff;
margin: 10% auto;
padding: 30px;
border: 1px solid #c3c4c7;
width: 80%;
max-width: 600px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
border-radius: 4px;
}
.apus-modal-close {
color: #646970;
float: right;
font-size: 28px;
font-weight: bold;
line-height: 20px;
cursor: pointer;
}
.apus-modal-close:hover,
.apus-modal-close:focus {
color: #1d2327;
}
.apus-modal-content h2 {
margin-top: 0;
}
.apus-modal-content textarea {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
}
/* Notices */
.apus-notice {
padding: 12px;
margin: 20px 0;
border-left: 4px solid;
background: #fff;
box-shadow: 0 1px 1px rgba(0,0,0,.04);
}
.apus-notice.success {
border-left-color: #00a32a;
}
.apus-notice.error {
border-left-color: #d63638;
}
.apus-notice.warning {
border-left-color: #dba617;
}
.apus-notice.info {
border-left-color: #2271b1;
}
/* Code Editor */
textarea.code {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.5;
}
/* Responsive */
@media screen and (max-width: 782px) {
.apus-options-container {
flex-direction: column;
}
.apus-tabs-nav {
width: 100%;
border-right: none;
border-bottom: 1px solid #c3c4c7;
}
.apus-tabs-nav ul {
display: flex;
flex-wrap: wrap;
}
.apus-tabs-nav li {
flex: 1;
min-width: 50%;
border-right: 1px solid #c3c4c7;
border-bottom: none;
}
.apus-tabs-nav li:first-child {
border-top: none;
}
.apus-tabs-nav a {
text-align: center;
padding: 12px 10px;
font-size: 13px;
}
.apus-tabs-nav a .dashicons {
display: block;
margin: 0 auto 5px;
}
.apus-tabs-nav li.active a {
border-left: none;
border-bottom: 3px solid #2271b1;
padding-left: 10px;
}
.apus-tabs-content {
padding: 20px;
}
.apus-options-header {
flex-direction: column;
gap: 15px;
}
.apus-options-actions {
width: 100%;
flex-direction: column;
}
.apus-options-actions .button {
width: 100%;
text-align: center;
}
.apus-tab-pane .form-table th {
width: auto;
padding: 15px 10px 5px 0;
display: block;
}
.apus-tab-pane .form-table td {
display: block;
padding: 5px 10px 15px 0;
}
}
/* Loading Spinner */
.apus-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0,0,0,.1);
border-radius: 50%;
border-top-color: #2271b1;
animation: apus-spin 1s ease-in-out infinite;
}
@keyframes apus-spin {
to { transform: rotate(360deg); }
}
/* Helper Classes */
.apus-hidden {
display: none !important;
}
.apus-text-center {
text-align: center;
}
.apus-mt-20 {
margin-top: 20px;
}
.apus-mb-20 {
margin-bottom: 20px;
}
/* Color Picker */
.wp-picker-container {
display: inline-block;
}
/* Field Dependencies */
.apus-field-dependency {
opacity: 0.5;
pointer-events: none;
}
/* Success Animation */
@keyframes apus-saved {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.apus-saved {
animation: apus-saved 0.3s ease-in-out;
}

View File

@@ -1,430 +0,0 @@
/**
* Admin Panel Application
*
* Gestión de configuraciones de componentes del tema
*
* @package Apus_Theme
* @since 2.0.0
*/
const AdminPanel = {
/**
* Estado de la aplicación
*/
STATE: {
settings: {},
hasChanges: false,
isLoading: false
},
/**
* Inicializar aplicación
*/
init() {
this.bindEvents();
this.loadSettings();
},
/**
* Vincular eventos
*/
bindEvents() {
// Botón guardar
document.getElementById('saveSettings').addEventListener('click', () => {
this.saveSettings();
});
// Detectar cambios en formularios
const enableSaveButton = () => {
this.STATE.hasChanges = true;
document.getElementById('saveSettings').disabled = false;
};
document.querySelectorAll('input, select, textarea').forEach(input => {
// Evento 'input' se dispara mientras se escribe (tiempo real)
input.addEventListener('input', enableSaveButton);
// Evento 'change' se dispara cuando pierde foco (para select y checkboxes)
input.addEventListener('change', enableSaveButton);
});
// Tabs
const tabs = document.querySelectorAll('.nav-tabs .nav-link');
tabs.forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
this.switchTab(tab);
});
});
// Contador de caracteres
this.setupCharacterCounter();
// Vista previa en tiempo real
this.setupLivePreview();
},
/**
* Configurar contador de caracteres para textarea
*/
setupCharacterCounter() {
const messageTextarea = document.getElementById('topBarMessageText');
if (!messageTextarea) return;
messageTextarea.addEventListener('input', () => {
const count = messageTextarea.value.length;
const maxLength = 250;
const percentage = (count / maxLength) * 100;
const counter = document.getElementById('topBarMessageTextCount');
const progress = document.getElementById('topBarMessageTextProgress');
if (counter) {
counter.textContent = count;
// Cambiar color según proximidad al límite
counter.classList.remove('text-danger', 'text-warning', 'text-muted');
if (count > 230) {
counter.classList.add('text-danger');
} else if (count > 200) {
counter.classList.add('text-warning');
} else {
counter.classList.add('text-muted');
}
}
// Actualizar progress bar
if (progress) {
progress.style.width = percentage + '%';
progress.setAttribute('aria-valuenow', count);
// Cambiar color del progress bar
progress.classList.remove('bg-orange-primary', 'bg-warning', 'bg-danger');
if (count > 230) {
progress.classList.add('bg-danger');
} else if (count > 200) {
progress.classList.add('bg-warning');
} else {
progress.classList.add('bg-orange-primary');
}
}
});
// Trigger inicial para mostrar count actual
messageTextarea.dispatchEvent(new Event('input'));
},
/**
* Configurar vista previa en tiempo real
*/
setupLivePreview() {
const preview = document.getElementById('topBarPreview');
if (!preview) return;
// Campos que afectan la vista previa
const fields = {
iconClass: document.getElementById('topBarIconClass'),
showIcon: document.getElementById('topBarShowIcon'),
highlightText: document.getElementById('topBarHighlightText'),
messageText: document.getElementById('topBarMessageText'),
linkText: document.getElementById('topBarLinkText'),
showLink: document.getElementById('topBarShowLink'),
bgColor: document.getElementById('topBarBgColor'),
textColor: document.getElementById('topBarTextColor'),
highlightColor: document.getElementById('topBarHighlightColor'),
fontSize: document.getElementById('topBarFontSize')
};
// Función para actualizar la vista previa
const updatePreview = () => {
// Obtener valores
const iconClass = fields.iconClass.value || 'bi bi-megaphone-fill';
const showIcon = fields.showIcon.checked;
const highlightText = fields.highlightText.value;
const messageText = fields.messageText.value || 'Tu mensaje aquí...';
const linkText = fields.linkText.value || 'Ver más';
const showLink = fields.showLink.checked;
const bgColor = fields.bgColor.value || '#0E2337';
const textColor = fields.textColor.value || '#ffffff';
const highlightColor = fields.highlightColor.value || '#FF8600';
// Mapeo de tamaños de fuente
const fontSizeMap = {
'small': '0.8rem',
'normal': '0.9rem',
'large': '1rem'
};
const fontSize = fontSizeMap[fields.fontSize.value] || '0.9rem';
// Construir HTML de la vista previa
let html = '';
// Icono
if (showIcon && iconClass) {
html += `<i class="${iconClass}" style="font-size: 1.2rem; color: ${highlightColor};"></i>`;
}
// Texto destacado
if (highlightText) {
html += `<span style="font-weight: 700; color: ${highlightColor};">${highlightText}</span>`;
}
// Mensaje principal
html += `<span style="flex: 1; min-width: 300px; text-align: center;">${messageText}</span>`;
// Enlace
if (showLink && linkText) {
html += `<a href="#" style="color: ${textColor}; text-decoration: underline; white-space: nowrap; transition: color 0.3s;">${linkText}</a>`;
}
// Actualizar la vista previa
preview.innerHTML = html;
preview.style.backgroundColor = bgColor;
preview.style.color = textColor;
preview.style.fontSize = fontSize;
};
// Agregar listeners a todos los campos
Object.values(fields).forEach(field => {
if (field) {
field.addEventListener('input', updatePreview);
field.addEventListener('change', updatePreview);
}
});
// Actualización inicial
updatePreview();
},
/**
* Cambiar tab
*/
switchTab(tab) {
// Remover active de todos
document.querySelectorAll('.nav-tabs .nav-link').forEach(t => {
t.classList.remove('active');
});
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.remove('show', 'active');
});
// Activar seleccionado
tab.classList.add('active');
const targetId = tab.getAttribute('data-bs-target').substring(1);
const targetPane = document.getElementById(targetId);
if (targetPane) {
targetPane.classList.add('show', 'active');
}
},
/**
* Cargar configuraciones desde servidor
*/
async loadSettings() {
this.STATE.isLoading = true;
this.showSpinner(true);
try {
const response = await axios({
method: 'POST',
url: apusAdminData.ajaxUrl,
data: new URLSearchParams({
action: 'apus_get_settings',
nonce: apusAdminData.nonce
})
});
if (response.data.success) {
this.STATE.settings = response.data.data;
this.renderAllComponents();
} else {
this.showNotice('Error al cargar configuraciones', 'error');
}
} catch (error) {
console.error('Error loading settings:', error);
this.showNotice('Error de conexión', 'error');
} finally {
this.STATE.isLoading = false;
this.showSpinner(false);
}
},
/**
* Guardar configuraciones al servidor
*/
async saveSettings() {
if (!this.STATE.hasChanges) {
this.showNotice('No hay cambios para guardar', 'info');
return;
}
this.showSpinner(true);
try {
const formData = this.collectFormData();
// Crear FormData para WordPress AJAX
const postData = new URLSearchParams();
postData.append('action', 'apus_save_settings');
postData.append('nonce', apusAdminData.nonce);
// Agregar components como JSON string
postData.append('components', JSON.stringify(formData.components));
const response = await axios({
method: 'POST',
url: apusAdminData.ajaxUrl,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: postData
});
if (response.data.success) {
this.STATE.hasChanges = false;
document.getElementById('saveSettings').disabled = true;
this.showNotice('Configuración guardada correctamente', 'success');
} else {
this.showNotice(response.data.data.message || 'Error al guardar', 'error');
}
} catch (error) {
console.error('Error saving settings:', error);
this.showNotice('Error de conexión', 'error');
} finally {
this.showSpinner(false);
}
},
/**
* Recolectar datos del formulario
* Cada componente agregará su sección aquí
*/
collectFormData() {
return {
components: {
top_bar: {
enabled: document.getElementById('topBarEnabled').checked,
show_on_mobile: document.getElementById('topBarShowOnMobile').checked,
show_on_desktop: document.getElementById('topBarShowOnDesktop').checked,
icon_class: document.getElementById('topBarIconClass').value.trim(),
show_icon: document.getElementById('topBarShowIcon').checked,
highlight_text: document.getElementById('topBarHighlightText').value.trim(),
message_text: document.getElementById('topBarMessageText').value.trim(),
link_text: document.getElementById('topBarLinkText').value.trim(),
link_url: document.getElementById('topBarLinkUrl').value.trim(),
link_target: document.getElementById('topBarLinkTarget').value,
show_link: document.getElementById('topBarShowLink').checked,
custom_styles: {
background_color: this.getColorValue('topBarBgColor', ''),
text_color: this.getColorValue('topBarTextColor', ''),
highlight_color: this.getColorValue('topBarHighlightColor', ''),
link_hover_color: this.getColorValue('topBarLinkHoverColor', ''),
font_size: document.getElementById('topBarFontSize').value
}
}
// Navbar - Pendiente
// Hero - Pendiente
// Footer - Pendiente
}
};
},
/**
* Renderizar todos los componentes
*/
renderAllComponents() {
const components = this.STATE.settings.components || {};
// Top Bar
if (components.top_bar) {
this.renderTopBar(components.top_bar);
}
// Navbar - Pendiente
// Hero - Pendiente
// Footer - Pendiente
},
/**
* Renderizar Top Bar
*/
renderTopBar(topBar) {
document.getElementById('topBarEnabled').checked = topBar.enabled !== undefined ? topBar.enabled : true;
document.getElementById('topBarShowOnMobile').checked = topBar.show_on_mobile !== undefined ? topBar.show_on_mobile : true;
document.getElementById('topBarShowOnDesktop').checked = topBar.show_on_desktop !== undefined ? topBar.show_on_desktop : true;
document.getElementById('topBarIconClass').value = topBar.icon_class || 'bi bi-megaphone-fill';
document.getElementById('topBarShowIcon').checked = topBar.show_icon !== undefined ? topBar.show_icon : true;
document.getElementById('topBarHighlightText').value = topBar.highlight_text || 'Nuevo:';
document.getElementById('topBarMessageText').value = topBar.message_text || 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
document.getElementById('topBarLinkText').value = topBar.link_text || 'Ver Catálogo';
document.getElementById('topBarLinkUrl').value = topBar.link_url || '/catalogo';
document.getElementById('topBarLinkTarget').value = topBar.link_target || '_self';
document.getElementById('topBarShowLink').checked = topBar.show_link !== undefined ? topBar.show_link : true;
// Estilos personalizados
if (topBar.custom_styles) {
if (topBar.custom_styles.background_color) {
document.getElementById('topBarBgColor').value = topBar.custom_styles.background_color;
}
if (topBar.custom_styles.text_color) {
document.getElementById('topBarTextColor').value = topBar.custom_styles.text_color;
}
if (topBar.custom_styles.highlight_color) {
document.getElementById('topBarHighlightColor').value = topBar.custom_styles.highlight_color;
}
if (topBar.custom_styles.link_hover_color) {
document.getElementById('topBarLinkHoverColor').value = topBar.custom_styles.link_hover_color;
}
document.getElementById('topBarFontSize').value = topBar.custom_styles.font_size || 'normal';
}
// Trigger contador de caracteres
const messageTextarea = document.getElementById('topBarMessageText');
if (messageTextarea) {
messageTextarea.dispatchEvent(new Event('input'));
}
},
/**
* Utilidad: Obtener valor de color con fallback
*/
getColorValue(inputId, defaultValue) {
const input = document.getElementById(inputId);
const value = input ? input.value.trim() : '';
return value || defaultValue;
},
/**
* Utilidad: Mostrar spinner
*/
showSpinner(show) {
const spinner = document.querySelector('.spinner');
if (spinner) {
spinner.style.display = show ? 'inline-block' : 'none';
}
},
/**
* Utilidad: Mostrar notificación
*/
showNotice(message, type = 'info') {
// WordPress admin notices
const noticeDiv = document.createElement('div');
noticeDiv.className = `notice notice-${type} is-dismissible`;
noticeDiv.innerHTML = `<p>${message}</p>`;
const container = document.querySelector('.apus-admin-panel');
if (container) {
container.insertBefore(noticeDiv, container.firstChild);
// Auto-dismiss después de 5 segundos
setTimeout(() => {
noticeDiv.remove();
}, 5000);
}
}
};
// Inicializar cuando el DOM esté listo
document.addEventListener('DOMContentLoaded', () => {
AdminPanel.init();
});

View File

@@ -1,191 +0,0 @@
/**
* Navbar Component - JavaScript Controller
*
* @package Apus_Theme
* @since 2.0.0
*/
window.NavbarComponent = {
/**
* Inicialización del componente
*/
init: function() {
console.log('Navbar component initialized');
// Actualizar valores hexadecimales de color pickers en tiempo real
this.initColorPickers();
},
/**
* Inicializar event listeners para color pickers
*/
initColorPickers: function() {
const colorPickers = [
{ input: 'navbarBgColor', display: 'navbarBgColorValue' },
{ input: 'navbarTextColor', display: 'navbarTextColorValue' },
{ input: 'navbarLinkHoverColor', display: 'navbarLinkHoverColorValue' },
{ input: 'navbarLinkHoverBgColor', display: 'navbarLinkHoverBgColorValue' },
{ input: 'navbarDropdownBgColor', display: 'navbarDropdownBgColorValue' },
{ input: 'navbarDropdownItemColor', display: 'navbarDropdownItemColorValue' },
{ input: 'navbarDropdownItemHoverColor', display: 'navbarDropdownItemHoverColorValue' }
];
colorPickers.forEach(function(picker) {
const inputEl = document.getElementById(picker.input);
const displayEl = document.getElementById(picker.display);
if (inputEl && displayEl) {
// Actualizar cuando cambia el color
inputEl.addEventListener('input', function() {
displayEl.textContent = inputEl.value.toUpperCase();
});
// Inicializar valor al cargar
displayEl.textContent = inputEl.value.toUpperCase();
}
});
},
/**
* Recolectar datos del formulario
* @returns {Object} Configuración del navbar
*/
collect: function() {
return {
// Grupo 1: Activación y Visibilidad
enabled: document.getElementById('navbarEnabled').checked,
show_on_mobile: document.getElementById('navbarShowOnMobile').checked,
show_on_desktop: document.getElementById('navbarShowOnDesktop').checked,
position: document.getElementById('navbarPosition').value,
responsive_breakpoint: document.getElementById('navbarBreakpoint').value,
// Grupo 4: Efectos (booleans)
enable_box_shadow: document.getElementById('navbarEnableBoxShadow').checked,
enable_underline_effect: document.getElementById('navbarEnableUnderlineEffect').checked,
enable_hover_background: document.getElementById('navbarEnableHoverBackground').checked,
// Grupo 6: Let's Talk Button
lets_talk_button: {
enabled: document.getElementById('navbarLetsTalkEnabled').checked,
text: document.getElementById('navbarLetsTalkText').value.trim(),
icon_class: document.getElementById('navbarLetsTalkIconClass').value.trim(),
show_icon: document.getElementById('navbarLetsTalkShowIcon').checked,
position: document.getElementById('navbarLetsTalkPosition').value
},
// Grupo 7: Dropdown
dropdown: {
enable_hover_desktop: document.getElementById('navbarDropdownEnableHoverDesktop').checked,
max_height: parseInt(document.getElementById('navbarDropdownMaxHeight').value) || 70,
border_radius: parseInt(document.getElementById('navbarDropdownBorderRadius').value) || 8,
item_padding_vertical: parseFloat(document.getElementById('navbarDropdownItemPaddingVertical').value) || 0.5,
item_padding_horizontal: parseFloat(document.getElementById('navbarDropdownItemPaddingHorizontal').value) || 1.25
},
// Grupos 2, 3, 5: Custom Styles
custom_styles: {
// Grupo 2: Colores
background_color: document.getElementById('navbarBgColor').value || '#1e3a5f',
text_color: document.getElementById('navbarTextColor').value || '#ffffff',
link_hover_color: document.getElementById('navbarLinkHoverColor').value || '#FF8600',
link_hover_bg_color: document.getElementById('navbarLinkHoverBgColor').value || '#FF8600',
dropdown_bg_color: document.getElementById('navbarDropdownBgColor').value || '#ffffff',
dropdown_item_color: document.getElementById('navbarDropdownItemColor').value || '#4A5568',
dropdown_item_hover_color: document.getElementById('navbarDropdownItemHoverColor').value || '#FF8600',
// Grupo 3: Tipografía
font_size: document.getElementById('navbarFontSize').value,
font_weight: document.getElementById('navbarFontWeight').value,
// Grupo 4: Efectos
box_shadow_intensity: document.getElementById('navbarBoxShadowIntensity').value,
border_radius: parseInt(document.getElementById('navbarBorderRadius').value) || 4,
// Grupo 5: Spacing
padding_vertical: parseFloat(document.getElementById('navbarPaddingVertical').value) || 0.75,
link_padding_vertical: parseFloat(document.getElementById('navbarLinkPaddingVertical').value) || 0.5,
link_padding_horizontal: parseFloat(document.getElementById('navbarLinkPaddingHorizontal').value) || 0.65,
// Grupo 8: Avanzado
z_index: parseInt(document.getElementById('navbarZIndex').value) || 1030,
transition_speed: document.getElementById('navbarTransitionSpeed').value || 'normal'
}
};
},
/**
* Renderizar configuración en el formulario
* @param {Object} config - Configuración del navbar
*/
render: function(config) {
if (!config) {
console.warn('No navbar config provided');
return;
}
// Grupo 1: Activación y Visibilidad
document.getElementById('navbarEnabled').checked = config.enabled !== undefined ? config.enabled : true;
document.getElementById('navbarShowOnMobile').checked = config.show_on_mobile !== undefined ? config.show_on_mobile : true;
document.getElementById('navbarShowOnDesktop').checked = config.show_on_desktop !== undefined ? config.show_on_desktop : true;
document.getElementById('navbarPosition').value = config.position || 'sticky';
document.getElementById('navbarBreakpoint').value = config.responsive_breakpoint || 'lg';
// Grupo 2: Colores Personalizados
if (config.custom_styles) {
document.getElementById('navbarBgColor').value = config.custom_styles.background_color || '#1e3a5f';
document.getElementById('navbarTextColor').value = config.custom_styles.text_color || '#ffffff';
document.getElementById('navbarLinkHoverColor').value = config.custom_styles.link_hover_color || '#FF8600';
document.getElementById('navbarLinkHoverBgColor').value = config.custom_styles.link_hover_bg_color || '#FF8600';
document.getElementById('navbarDropdownBgColor').value = config.custom_styles.dropdown_bg_color || '#ffffff';
document.getElementById('navbarDropdownItemColor').value = config.custom_styles.dropdown_item_color || '#4A5568';
document.getElementById('navbarDropdownItemHoverColor').value = config.custom_styles.dropdown_item_hover_color || '#FF8600';
}
// Grupo 3: Tipografía
if (config.custom_styles) {
document.getElementById('navbarFontSize').value = config.custom_styles.font_size || 'normal';
document.getElementById('navbarFontWeight').value = config.custom_styles.font_weight || '500';
}
// Grupo 4: Efectos Visuales
document.getElementById('navbarEnableBoxShadow').checked = config.enable_box_shadow !== undefined ? config.enable_box_shadow : true;
document.getElementById('navbarEnableUnderlineEffect').checked = config.enable_underline_effect !== undefined ? config.enable_underline_effect : true;
document.getElementById('navbarEnableHoverBackground').checked = config.enable_hover_background !== undefined ? config.enable_hover_background : true;
if (config.custom_styles) {
document.getElementById('navbarBoxShadowIntensity').value = config.custom_styles.box_shadow_intensity || 'normal';
document.getElementById('navbarBorderRadius').value = config.custom_styles.border_radius !== undefined ? config.custom_styles.border_radius : 4;
}
// Grupo 5: Spacing
if (config.custom_styles) {
document.getElementById('navbarPaddingVertical').value = config.custom_styles.padding_vertical !== undefined ? config.custom_styles.padding_vertical : 0.75;
document.getElementById('navbarLinkPaddingVertical').value = config.custom_styles.link_padding_vertical !== undefined ? config.custom_styles.link_padding_vertical : 0.5;
document.getElementById('navbarLinkPaddingHorizontal').value = config.custom_styles.link_padding_horizontal !== undefined ? config.custom_styles.link_padding_horizontal : 0.65;
}
// Grupo 8: Avanzado
if (config.custom_styles) {
document.getElementById('navbarZIndex').value = config.custom_styles.z_index !== undefined ? config.custom_styles.z_index : 1030;
document.getElementById('navbarTransitionSpeed').value = config.custom_styles.transition_speed || 'normal';
}
// Grupo 6: Let's Talk Button
if (config.lets_talk_button) {
document.getElementById('navbarLetsTalkEnabled').checked = config.lets_talk_button.enabled !== undefined ? config.lets_talk_button.enabled : true;
document.getElementById('navbarLetsTalkText').value = config.lets_talk_button.text || "Let's Talk";
document.getElementById('navbarLetsTalkIconClass').value = config.lets_talk_button.icon_class || 'bi bi-lightning-charge-fill';
document.getElementById('navbarLetsTalkShowIcon').checked = config.lets_talk_button.show_icon !== undefined ? config.lets_talk_button.show_icon : true;
document.getElementById('navbarLetsTalkPosition').value = config.lets_talk_button.position || 'right';
}
// Grupo 7: Dropdown
if (config.dropdown) {
document.getElementById('navbarDropdownEnableHoverDesktop').checked = config.dropdown.enable_hover_desktop !== undefined ? config.dropdown.enable_hover_desktop : true;
document.getElementById('navbarDropdownMaxHeight').value = config.dropdown.max_height !== undefined ? config.dropdown.max_height : 70;
document.getElementById('navbarDropdownBorderRadius').value = config.dropdown.border_radius !== undefined ? config.dropdown.border_radius : 8;
document.getElementById('navbarDropdownItemPaddingVertical').value = config.dropdown.item_padding_vertical !== undefined ? config.dropdown.item_padding_vertical : 0.5;
document.getElementById('navbarDropdownItemPaddingHorizontal').value = config.dropdown.item_padding_horizontal !== undefined ? config.dropdown.item_padding_horizontal : 1.25;
}
}
};

View File

@@ -1,440 +0,0 @@
/**
* Theme Options Admin JavaScript
*
* @package Apus_Theme
* @since 1.0.0
*/
(function($) {
'use strict';
var ApusThemeOptions = {
/**
* Initialize
*/
init: function() {
this.tabs();
this.imageUpload();
this.resetOptions();
this.exportOptions();
this.importOptions();
this.formValidation();
this.conditionalFields();
},
/**
* Tab Navigation
*/
tabs: function() {
// Tab click handler
$('.apus-tabs-nav a').on('click', function(e) {
e.preventDefault();
var tabId = $(this).attr('href');
// Update active states
$('.apus-tabs-nav li').removeClass('active');
$(this).parent().addClass('active');
// Show/hide tab content
$('.apus-tab-pane').removeClass('active');
$(tabId).addClass('active');
// Update URL hash without scrolling
if (history.pushState) {
history.pushState(null, null, tabId);
} else {
window.location.hash = tabId;
}
});
// Load tab from URL hash on page load
if (window.location.hash) {
var hash = window.location.hash;
if ($(hash).length) {
$('.apus-tabs-nav a[href="' + hash + '"]').trigger('click');
}
}
// Handle browser back/forward buttons
$(window).on('hashchange', function() {
if (window.location.hash) {
$('.apus-tabs-nav a[href="' + window.location.hash + '"]').trigger('click');
}
});
},
/**
* Image Upload
*/
imageUpload: function() {
var self = this;
var mediaUploader;
// Upload button click
$(document).on('click', '.apus-upload-image', function(e) {
e.preventDefault();
var button = $(this);
var container = button.closest('.apus-image-upload');
var preview = container.find('.apus-image-preview');
var input = container.find('.apus-image-id');
var removeBtn = container.find('.apus-remove-image');
// If the media uploader already exists, reopen it
if (mediaUploader) {
mediaUploader.open();
return;
}
// Create new media uploader
mediaUploader = wp.media({
title: apusAdminOptions.strings.selectImage,
button: {
text: apusAdminOptions.strings.useImage
},
multiple: false
});
// When an image is selected
mediaUploader.on('select', function() {
var attachment = mediaUploader.state().get('selection').first().toJSON();
// Set image ID
input.val(attachment.id);
// Show preview
var imgUrl = attachment.sizes && attachment.sizes.medium ?
attachment.sizes.medium.url : attachment.url;
preview.html('<img src="' + imgUrl + '" class="apus-preview-image" />');
// Show remove button
removeBtn.show();
});
// Open the uploader
mediaUploader.open();
});
// Remove button click
$(document).on('click', '.apus-remove-image', function(e) {
e.preventDefault();
var button = $(this);
var container = button.closest('.apus-image-upload');
var preview = container.find('.apus-image-preview');
var input = container.find('.apus-image-id');
// Clear values
input.val('');
preview.empty();
button.hide();
});
},
/**
* Reset Options
*/
resetOptions: function() {
$('#apus-reset-options').on('click', function(e) {
e.preventDefault();
if (!confirm(apusAdminOptions.strings.confirmReset)) {
return;
}
var button = $(this);
button.prop('disabled', true).addClass('updating-message');
$.ajax({
url: apusAdminOptions.ajaxUrl,
type: 'POST',
data: {
action: 'apus_reset_options',
nonce: apusAdminOptions.nonce
},
success: function(response) {
if (response.success) {
// Show success message
ApusThemeOptions.showNotice('success', response.data.message);
// Reload page after 1 second
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
ApusThemeOptions.showNotice('error', response.data.message);
button.prop('disabled', false).removeClass('updating-message');
}
},
error: function() {
ApusThemeOptions.showNotice('error', apusAdminOptions.strings.error);
button.prop('disabled', false).removeClass('updating-message');
}
});
});
},
/**
* Export Options
*/
exportOptions: function() {
$('#apus-export-options').on('click', function(e) {
e.preventDefault();
var button = $(this);
button.prop('disabled', true).addClass('updating-message');
$.ajax({
url: apusAdminOptions.ajaxUrl,
type: 'POST',
data: {
action: 'apus_export_options',
nonce: apusAdminOptions.nonce
},
success: function(response) {
if (response.success) {
// Create download link
var blob = new Blob([response.data.data], { type: 'application/json' });
var url = window.URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = response.data.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
ApusThemeOptions.showNotice('success', 'Options exported successfully!');
} else {
ApusThemeOptions.showNotice('error', response.data.message);
}
button.prop('disabled', false).removeClass('updating-message');
},
error: function() {
ApusThemeOptions.showNotice('error', apusAdminOptions.strings.error);
button.prop('disabled', false).removeClass('updating-message');
}
});
});
},
/**
* Import Options
*/
importOptions: function() {
var modal = $('#apus-import-modal');
var importData = $('#apus-import-data');
// Show modal
$('#apus-import-options').on('click', function(e) {
e.preventDefault();
modal.show();
});
// Close modal
$('.apus-modal-close, #apus-import-cancel').on('click', function() {
modal.hide();
importData.val('');
});
// Close modal on outside click
$(window).on('click', function(e) {
if ($(e.target).is(modal)) {
modal.hide();
importData.val('');
}
});
// Submit import
$('#apus-import-submit').on('click', function(e) {
e.preventDefault();
var data = importData.val().trim();
if (!data) {
alert('Please paste your import data.');
return;
}
var button = $(this);
button.prop('disabled', true).addClass('updating-message');
$.ajax({
url: apusAdminOptions.ajaxUrl,
type: 'POST',
data: {
action: 'apus_import_options',
nonce: apusAdminOptions.nonce,
import_data: data
},
success: function(response) {
if (response.success) {
ApusThemeOptions.showNotice('success', response.data.message);
modal.hide();
importData.val('');
// Reload page after 1 second
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
ApusThemeOptions.showNotice('error', response.data.message);
button.prop('disabled', false).removeClass('updating-message');
}
},
error: function() {
ApusThemeOptions.showNotice('error', apusAdminOptions.strings.error);
button.prop('disabled', false).removeClass('updating-message');
}
});
});
},
/**
* Form Validation
*/
formValidation: function() {
$('.apus-options-form').on('submit', function(e) {
var valid = true;
var firstError = null;
// Validate required fields
$(this).find('[required]').each(function() {
if (!$(this).val()) {
valid = false;
$(this).addClass('error');
if (!firstError) {
firstError = $(this);
}
} else {
$(this).removeClass('error');
}
});
// Validate number fields
$(this).find('input[type="number"]').each(function() {
var val = $(this).val();
var min = $(this).attr('min');
var max = $(this).attr('max');
if (val && min && parseInt(val) < parseInt(min)) {
valid = false;
$(this).addClass('error');
if (!firstError) {
firstError = $(this);
}
}
if (val && max && parseInt(val) > parseInt(max)) {
valid = false;
$(this).addClass('error');
if (!firstError) {
firstError = $(this);
}
}
});
// Validate URL fields
$(this).find('input[type="url"]').each(function() {
var val = $(this).val();
if (val && !ApusThemeOptions.isValidUrl(val)) {
valid = false;
$(this).addClass('error');
if (!firstError) {
firstError = $(this);
}
}
});
if (!valid) {
e.preventDefault();
if (firstError) {
// Scroll to first error
$('html, body').animate({
scrollTop: firstError.offset().top - 100
}, 500);
firstError.focus();
}
ApusThemeOptions.showNotice('error', 'Please fix the errors in the form.');
return false;
}
// Add saving animation
$(this).find('.submit .button-primary').addClass('updating-message');
});
// Remove error class on input
$('.apus-options-form input, .apus-options-form select, .apus-options-form textarea').on('change input', function() {
$(this).removeClass('error');
});
},
/**
* Conditional Fields
*/
conditionalFields: function() {
// Enable/disable related posts options based on checkbox
$('#enable_related_posts').on('change', function() {
var checked = $(this).is(':checked');
var fields = $('#related_posts_count, #related_posts_taxonomy, #related_posts_title, #related_posts_columns');
fields.closest('tr').toggleClass('apus-field-dependency', !checked);
fields.prop('disabled', !checked);
}).trigger('change');
// Enable/disable breadcrumb separator based on breadcrumbs checkbox
$('#enable_breadcrumbs').on('change', function() {
var checked = $(this).is(':checked');
var field = $('#breadcrumb_separator');
field.closest('tr').toggleClass('apus-field-dependency', !checked);
field.prop('disabled', !checked);
}).trigger('change');
},
/**
* Show Notice
*/
showNotice: function(type, message) {
var notice = $('<div class="notice notice-' + type + ' is-dismissible"><p>' + message + '</p></div>');
$('.apus-theme-options h1').after(notice);
// Auto-dismiss after 5 seconds
setTimeout(function() {
notice.fadeOut(function() {
$(this).remove();
});
}, 5000);
// Scroll to top
$('html, body').animate({ scrollTop: 0 }, 300);
},
/**
* Validate URL
*/
isValidUrl: function(url) {
try {
new URL(url);
return true;
} catch (e) {
return false;
}
}
};
// Initialize on document ready
$(document).ready(function() {
ApusThemeOptions.init();
});
// Make it globally accessible
window.ApusThemeOptions = ApusThemeOptions;
})(jQuery);

View File

@@ -1,604 +0,0 @@
<?php
/**
* Admin Panel - Hero Section Component
*
* Tab panel para configurar el Hero Section del tema
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
?>
<!-- ============================================================
TAB: HERO SECTION CONFIGURATION
============================================================ -->
<div class="tab-pane fade" id="heroSectionTab" role="tabpanel" aria-labelledby="heroSection-tab">
<!-- ========================================
PATRÓN 1: HEADER CON GRADIENTE
======================================== -->
<div class="rounded p-4 mb-4 shadow text-white" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h3 class="h4 mb-1 fw-bold">
<i class="bi bi-image me-2" style="color: #FF8600;"></i>
Configuración del Hero Section
</h3>
<p class="mb-0 small" style="opacity: 0.85;">
Personaliza el banner principal con título y categorías
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-light" id="resetHeroSectionDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- ========================================
PATRÓN 2: LAYOUT 2 COLUMNAS
======================================== -->
<div class="row g-3">
<!-- ========================================
COLUMNA IZQUIERDA
======================================== -->
<div class="col-lg-6">
<!-- ========================================
GRUPO 1: ACTIVACIÓN Y VISIBILIDAD (OBLIGATORIO)
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>
Activación y Visibilidad
</h5>
<!-- Switch 1: Enabled (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionEnabled" checked>
<label class="form-check-label small" for="heroSectionEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Activar Hero Section</strong>
</label>
</div>
</div>
<!-- Switch 2: Show on Mobile (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionShowOnMobile" checked>
<label class="form-check-label small" for="heroSectionShowOnMobile" style="color: #495057;">
<i class="bi bi-phone me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>
</label>
</div>
</div>
<!-- Switch 3: Show on Desktop (OBLIGATORIO) -->
<div class="mb-0">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionShowOnDesktop" checked>
<label class="form-check-label small" for="heroSectionShowOnDesktop" style="color: #495057;">
<i class="bi bi-display me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>
</label>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 2: CONTENIDO Y ESTRUCTURA
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-card-text me-2" style="color: #FF8600;"></i>
Contenido y Estructura
</h5>
<!-- Switch: Show Category Badges -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionShowCategoryBadges" checked>
<label class="form-check-label small" for="heroSectionShowCategoryBadges" style="color: #495057;">
<i class="bi bi-folder me-1" style="color: #FF8600;"></i>
<strong>Mostrar Category Badges</strong>
</label>
</div>
</div>
<!-- Text input: Badge Icon -->
<div class="mb-2">
<label for="heroSectionCategoryBadgeIcon" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-stars me-1" style="color: #FF8600;"></i>
Icono de Category Badge
</label>
<input type="text" id="heroSectionCategoryBadgeIcon" class="form-control form-control-sm" value="bi bi-folder-fill" placeholder="bi bi-...">
<small class="text-muted">Clase de Bootstrap Icons</small>
</div>
<!-- Textarea: Excluded Categories -->
<div class="mb-2">
<label for="heroSectionExcludedCategories" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-x-circle me-1" style="color: #FF8600;"></i>
Categorías Excluidas
</label>
<textarea id="heroSectionExcludedCategories" class="form-control form-control-sm" rows="2" placeholder="Una por línea">Uncategorized
Sin categoría</textarea>
<small class="text-muted">Una categoría por línea</small>
</div>
<!-- Compacted row: Alignment + Display Class -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="heroSectionTitleAlignment" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-text-center me-1" style="color: #FF8600;"></i>
Alineación Título
</label>
<select id="heroSectionTitleAlignment" class="form-select form-select-sm">
<option value="left">Izquierda</option>
<option value="center" selected>Centro</option>
<option value="right">Derecha</option>
</select>
</div>
<div class="col-6">
<label for="heroSectionTitleDisplayClass" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type-h1 me-1" style="color: #FF8600;"></i>
Clase Display
</label>
<select id="heroSectionTitleDisplayClass" class="form-select form-select-sm">
<option value="display-1">display-1</option>
<option value="display-2">display-2</option>
<option value="display-3">display-3</option>
<option value="display-4">display-4</option>
<option value="display-5" selected>display-5</option>
<option value="display-6">display-6</option>
</select>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 3: COLORES DEL HERO
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-palette me-2" style="color: #FF8600;"></i>
Colores del Hero
</h5>
<!-- Switch: Use Gradient Background -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionUseGradientBackground" checked>
<label class="form-check-label small" for="heroSectionUseGradientBackground" style="color: #495057;">
<i class="bi bi-palette-fill me-1" style="color: #FF8600;"></i>
<strong>Usar Gradiente de Fondo</strong>
</label>
</div>
</div>
<!-- Color pickers: Grid 2x2 (primera fila) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionGradientStartColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Color Gradiente Inicio
</label>
<input type="color" id="heroSectionGradientStartColor" class="form-control form-control-color w-100" value="#1e3a5f" title="Seleccionar color gradiente inicio">
<small class="text-muted d-block mt-1" id="heroSectionGradientStartColorValue">#1E3A5F</small>
</div>
<div class="col-6">
<label for="heroSectionGradientEndColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket-fill me-1" style="color: #FF8600;"></i>
Color Gradiente Fin
</label>
<input type="color" id="heroSectionGradientEndColor" class="form-control form-control-color w-100" value="#2c5282" title="Seleccionar color gradiente fin">
<small class="text-muted d-block mt-1" id="heroSectionGradientEndColorValue">#2C5282</small>
</div>
</div>
<!-- Color pickers: Grid 2x2 (segunda fila) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionHeroTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Color Texto H1
</label>
<input type="color" id="heroSectionHeroTextColor" class="form-control form-control-color w-100" value="#ffffff" title="Seleccionar color texto">
<small class="text-muted d-block mt-1" id="heroSectionHeroTextColorValue">#ffffff</small>
</div>
<div class="col-6">
<label for="heroSectionSolidBackgroundColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-square-fill me-1" style="color: #FF8600;"></i>
Color Fondo Sólido
</label>
<input type="color" id="heroSectionSolidBackgroundColor" class="form-control form-control-color w-100" value="#1e3a5f" title="Seleccionar color fondo sólido">
<small class="text-muted d-block mt-1" id="heroSectionSolidBackgroundColorValue">#1E3A5F</small>
</div>
</div>
<!-- Range: Gradient Angle -->
<div class="mb-0">
<label for="heroSectionGradientAngle" class="form-label small mb-1 fw-semibold d-flex justify-content-between align-items-center" style="color: #495057;">
<span>
<i class="bi bi-arrow-clockwise me-1" style="color: #FF8600;"></i>
Ángulo del Gradiente
</span>
<span class="badge bg-secondary" id="heroSectionGradientAngleValue">135°</span>
</label>
<input type="range" id="heroSectionGradientAngle" class="form-range" min="0" max="360" step="1" value="135">
</div>
</div>
</div>
<!-- ========================================
GRUPO 4: COLORES DE CATEGORY BADGES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-folder-fill me-2" style="color: #FF8600;"></i>
Colores de Category Badges
</h5>
<!-- Color pickers: Grid 2x2 (primera fila) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionBadgeBgColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Background Badge
</label>
<input type="text" id="heroSectionBadgeBgColor" class="form-control form-control-sm" value="rgba(255, 255, 255, 0.15)" placeholder="rgba(...)">
<small class="text-muted">Soporta RGBA</small>
</div>
<div class="col-6">
<label for="heroSectionBadgeBgHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>
Background Hover
</label>
<input type="text" id="heroSectionBadgeBgHoverColor" class="form-control form-control-sm" value="rgba(255, 133, 0, 0.2)" placeholder="rgba(...)">
<small class="text-muted">Soporta RGBA</small>
</div>
</div>
<!-- Color pickers: Grid 2x2 (segunda fila) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionBadgeBorderColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-border me-1" style="color: #FF8600;"></i>
Border Badge
</label>
<input type="text" id="heroSectionBadgeBorderColor" class="form-control form-control-sm" value="rgba(255, 255, 255, 0.2)" placeholder="rgba(...)">
<small class="text-muted">Soporta RGBA</small>
</div>
<div class="col-6">
<label for="heroSectionBadgeTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Texto Badge
</label>
<input type="text" id="heroSectionBadgeTextColor" class="form-control form-control-sm" value="rgba(255, 255, 255, 0.95)" placeholder="rgba(...)">
<small class="text-muted">Soporta RGBA</small>
</div>
</div>
<!-- Color picker: Icon Color (full width) -->
<div class="mb-0">
<label for="heroSectionBadgeIconColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-stars me-1" style="color: #FF8600;"></i>
Color Icono Badge
</label>
<input type="color" id="heroSectionBadgeIconColor" class="form-control form-control-color w-100" value="#FFB800" title="Seleccionar color icono">
<small class="text-muted d-block mt-1" id="heroSectionBadgeIconColorValue">#FFB800</small>
</div>
</div>
</div>
</div>
<!-- ========================================
COLUMNA DERECHA
======================================== -->
<div class="col-lg-6">
<!-- ========================================
GRUPO 5: ESPACIADO Y DIMENSIONES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-arrows-fullscreen me-2" style="color: #FF8600;"></i>
Espaciado y Dimensiones
</h5>
<!-- Hero padding compactado -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionHeroPaddingVertical" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-vertical me-1" style="color: #FF8600;"></i>
Padding Vertical (rem)
</label>
<input type="number" id="heroSectionHeroPaddingVertical" class="form-control form-control-sm" value="3" min="0" max="10" step="0.5">
</div>
<div class="col-6">
<label for="heroSectionHeroPaddingHorizontal" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>
Padding Horizontal (rem)
</label>
<input type="number" id="heroSectionHeroPaddingHorizontal" class="form-control form-control-sm" value="0" min="0" max="10" step="0.5">
</div>
</div>
<!-- Margin + Gap compactado -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionHeroMarginBottom" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrow-down me-1" style="color: #FF8600;"></i>
Margin Bottom (rem)
</label>
<input type="number" id="heroSectionHeroMarginBottom" class="form-control form-control-sm" value="1.5" min="0" max="5" step="0.5">
</div>
<div class="col-6">
<label for="heroSectionBadgesGap" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-distribute-horizontal me-1" style="color: #FF8600;"></i>
Gap Badges (rem)
</label>
<input type="number" id="heroSectionBadgesGap" class="form-control form-control-sm" value="0.5" min="0" max="3" step="0.1">
</div>
</div>
<!-- Badge padding compactado -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionBadgePaddingVertical" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-vertical me-1" style="color: #FF8600;"></i>
Badge Padding V (rem)
</label>
<input type="number" id="heroSectionBadgePaddingVertical" class="form-control form-control-sm" value="0.375" min="0" max="2" step="0.125">
</div>
<div class="col-6">
<label for="heroSectionBadgePaddingHorizontal" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>
Badge Padding H (rem)
</label>
<input type="number" id="heroSectionBadgePaddingHorizontal" class="form-control form-control-sm" value="0.875" min="0" max="2" step="0.125">
</div>
</div>
<!-- Border radius -->
<div class="mb-0">
<label for="heroSectionBadgeBorderRadius" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-diamond me-1" style="color: #FF8600;"></i>
Border Radius Badge (px)
</label>
<input type="number" id="heroSectionBadgeBorderRadius" class="form-control form-control-sm" value="20" min="0" max="50" step="1">
</div>
</div>
</div>
<!-- ========================================
GRUPO 6: TIPOGRAFÍA
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-type me-2" style="color: #FF8600;"></i>
Tipografía
</h5>
<!-- H1 Font Weight + Badge Font Size compactado -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="heroSectionH1FontWeight" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type-bold me-1" style="color: #FF8600;"></i>
Font Weight H1
</label>
<select id="heroSectionH1FontWeight" class="form-select form-select-sm">
<option value="400">400 (Normal)</option>
<option value="500">500 (Medium)</option>
<option value="600">600 (Semibold)</option>
<option value="700" selected>700 (Bold)</option>
</select>
</div>
<div class="col-6">
<label for="heroSectionBadgeFontSize" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Badge Font Size (rem)
</label>
<input type="number" id="heroSectionBadgeFontSize" class="form-control form-control-sm" value="0.813" min="0.5" max="2" step="0.125">
</div>
</div>
<!-- Badge Font Weight + H1 Line Height compactado -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="heroSectionBadgeFontWeight" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type-bold me-1" style="color: #FF8600;"></i>
Font Weight Badge
</label>
<select id="heroSectionBadgeFontWeight" class="form-select form-select-sm">
<option value="400">400 (Normal)</option>
<option value="500" selected>500 (Medium)</option>
<option value="600">600 (Semibold)</option>
<option value="700">700 (Bold)</option>
</select>
</div>
<div class="col-6">
<label for="heroSectionH1LineHeight" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-text-paragraph me-1" style="color: #FF8600;"></i>
Line Height H1
</label>
<input type="number" id="heroSectionH1LineHeight" class="form-control form-control-sm" value="1.4" min="1" max="3" step="0.1">
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 7: EFECTOS VISUALES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-stars me-2" style="color: #FF8600;"></i>
Efectos Visuales
</h5>
<!-- Switch: Enable H1 Text Shadow -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionEnableH1TextShadow" checked>
<label class="form-check-label small" for="heroSectionEnableH1TextShadow" style="color: #495057;">
<i class="bi bi-sun me-1" style="color: #FF8600;"></i>
<strong>Habilitar Text Shadow H1</strong>
</label>
</div>
</div>
<!-- Text input: H1 Text Shadow -->
<div class="mb-2">
<label for="heroSectionH1TextShadow" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-droplet me-1" style="color: #FF8600;"></i>
Text Shadow H1
</label>
<input type="text" id="heroSectionH1TextShadow" class="form-control form-control-sm" value="1px 1px 2px rgba(0, 0, 0, 0.2)" placeholder="CSS shadow">
<small class="text-muted">Sintaxis CSS: x y blur color</small>
</div>
<!-- Switch: Enable Hero Box Shadow -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionEnableHeroBoxShadow" checked>
<label class="form-check-label small" for="heroSectionEnableHeroBoxShadow" style="color: #495057;">
<i class="bi bi-box me-1" style="color: #FF8600;"></i>
<strong>Habilitar Box Shadow Hero</strong>
</label>
</div>
</div>
<!-- Text input: Hero Box Shadow -->
<div class="mb-2">
<label for="heroSectionHeroBoxShadow" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-box-seam me-1" style="color: #FF8600;"></i>
Box Shadow Hero
</label>
<input type="text" id="heroSectionHeroBoxShadow" class="form-control form-control-sm" value="0 4px 16px rgba(30, 58, 95, 0.25)" placeholder="CSS shadow">
<small class="text-muted">Sintaxis CSS: x y blur spread color</small>
</div>
<!-- Switch: Enable Badge Backdrop Filter -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="heroSectionEnableBadgeBackdropFilter" checked>
<label class="form-check-label small" for="heroSectionEnableBadgeBackdropFilter" style="color: #495057;">
<i class="bi bi-bounding-box me-1" style="color: #FF8600;"></i>
<strong>Habilitar Backdrop Filter Badge</strong>
</label>
</div>
</div>
<!-- Text input: Badge Backdrop Filter -->
<div class="mb-0">
<label for="heroSectionBadgeBackdropFilter" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-filter me-1" style="color: #FF8600;"></i>
Backdrop Filter Badge
</label>
<input type="text" id="heroSectionBadgeBackdropFilter" class="form-control form-control-sm" value="blur(10px)" placeholder="CSS filter">
<small class="text-muted">Ej: blur(10px), brightness(1.2)</small>
</div>
</div>
</div>
<!-- ========================================
GRUPO 8: TRANSICIONES Y ANIMACIONES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-lightning me-2" style="color: #FF8600;"></i>
Transiciones y Animaciones
</h5>
<!-- Compacted row: Transition Speed + Hover Effect -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="heroSectionBadgeTransitionSpeed" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-speedometer me-1" style="color: #FF8600;"></i>
Velocidad Transición
</label>
<select id="heroSectionBadgeTransitionSpeed" class="form-select form-select-sm">
<option value="fast">Rápida (0.15s)</option>
<option value="normal" selected>Normal (0.3s)</option>
<option value="slow">Lenta (0.5s)</option>
</select>
</div>
<div class="col-6">
<label for="heroSectionBadgeHoverEffect" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>
Efecto Hover
</label>
<select id="heroSectionBadgeHoverEffect" class="form-select form-select-sm">
<option value="none">Ninguno</option>
<option value="background" selected>Background</option>
<option value="scale">Escala</option>
<option value="brightness">Brillo</option>
</select>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 9: AVANZADO
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-code-slash me-2" style="color: #FF8600;"></i>
Avanzado
</h5>
<!-- Text input: Custom Hero Classes -->
<div class="mb-2">
<label for="heroSectionCustomHeroClasses" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-braces me-1" style="color: #FF8600;"></i>
Custom CSS Classes Hero
</label>
<input type="text" id="heroSectionCustomHeroClasses" class="form-control form-control-sm" value="" placeholder="custom-class-1 custom-class-2">
<small class="text-muted">Clases CSS adicionales separadas por espacio</small>
</div>
<!-- Text input: Custom Badge Classes -->
<div class="mb-0">
<label for="heroSectionCustomBadgeClasses" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-braces-asterisk me-1" style="color: #FF8600;"></i>
Custom CSS Classes Badge
</label>
<input type="text" id="heroSectionCustomBadgeClasses" class="form-control form-control-sm" value="" placeholder="badge-custom-1">
<small class="text-muted">Clases CSS adicionales para badges</small>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,393 +0,0 @@
<?php
/**
* Component: Let's Talk Button Configuration
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
?>
<!-- ============================================================
TAB: BOTÓN LET'S TALK CONFIGURATION
============================================================ -->
<div class="tab-pane fade" id="letsTalkButtonTab" role="tabpanel" aria-labelledby="lets-talk-button-config-tab">
<!-- ========================================
PATRÓN 1: HEADER CON GRADIENTE
======================================== -->
<div class="rounded p-4 mb-4 shadow text-white" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h3 class="h4 mb-1 fw-bold">
<i class="bi bi-lightning-charge-fill me-2" style="color: #FF8600;"></i>
Configuración del Botón Let's Talk
</h3>
<p class="mb-0 small" style="opacity: 0.85;">
Personaliza el botón de contacto "Let's Talk" del navbar
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-light" id="resetLetsTalkButtonDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- ========================================
PATRÓN 2: LAYOUT 2 COLUMNAS
======================================== -->
<div class="row g-3">
<div class="col-lg-6">
<!-- ========================================
GRUPO 1: ACTIVACIÓN Y VISIBILIDAD (3 campos)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
PATRÓN 4: 3 SWITCHES OBLIGATORIOS
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>
Activación y Visibilidad
</h5>
<!-- Switch 1: Enabled (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="letsTalkButtonEnabled" checked>
<label class="form-check-label small" for="letsTalkButtonEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Activar Botón Let's Talk</strong>
</label>
</div>
</div>
<!-- Switch 2: Show on Mobile (OBLIGATORIO) -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="letsTalkButtonShowOnMobile" checked>
<label class="form-check-label small" for="letsTalkButtonShowOnMobile" style="color: #495057;">
<i class="bi bi-phone me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>
</label>
</div>
</div>
<!-- Switch 3: Show on Desktop (OBLIGATORIO) -->
<div class="mb-0">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="letsTalkButtonShowOnDesktop" checked>
<label class="form-check-label small" for="letsTalkButtonShowOnDesktop" style="color: #495057;">
<i class="bi bi-display me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>
</label>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 2: CONTENIDO (3 campos)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-chat-text me-2" style="color: #FF8600;"></i>
Contenido
</h5>
<!-- Switch: show_icon -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="letsTalkButtonShowIcon" checked>
<label class="form-check-label small" for="letsTalkButtonShowIcon" style="color: #495057;">
<i class="bi bi-eye me-1" style="color: #FF8600;"></i>
<strong>Mostrar icono</strong>
</label>
</div>
</div>
<!-- Text inputs compactados: text + icon_class -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="letsTalkButtonText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Texto del botón
</label>
<input type="text" id="letsTalkButtonText" class="form-control form-control-sm" value="Let's Talk" maxlength="30">
<small class="text-muted">Máximo 30 caracteres</small>
</div>
<div class="col-6">
<label for="letsTalkButtonIconClass" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-star me-1" style="color: #FF8600;"></i>
Clase del icono
</label>
<input type="text" id="letsTalkButtonIconClass" class="form-control form-control-sm" value="bi bi-lightning-charge-fill" placeholder="bi bi-...">
<small class="text-muted">Bootstrap Icons</small>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 3: TIPOGRAFÍA (1 campo)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-fonts me-2" style="color: #FF8600;"></i>
Tipografía
</h5>
<!-- Select: font_weight -->
<div class="mb-0">
<label for="letsTalkButtonFontWeight" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type-bold me-1" style="color: #FF8600;"></i>
Peso de fuente
</label>
<select id="letsTalkButtonFontWeight" class="form-select form-select-sm">
<option value="400">Normal (400)</option>
<option value="500">Medium (500)</option>
<option value="600" selected>Semibold (600)</option>
<option value="700">Bold (700)</option>
</select>
</div>
</div>
</div>
<!-- ========================================
GRUPO 4: COMPORTAMIENTO (1 campo)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-gear me-2" style="color: #FF8600;"></i>
Comportamiento
</h5>
<!-- Text input: modal_target -->
<div class="mb-0">
<label for="letsTalkButtonModalTarget" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-window me-1" style="color: #FF8600;"></i>
ID del modal
</label>
<input type="text" id="letsTalkButtonModalTarget" class="form-control form-control-sm" value="#contactModal" maxlength="50" placeholder="#nombreModal">
<small class="text-muted">Debe comenzar con #</small>
</div>
</div>
</div>
<!-- ========================================
GRUPO 5: ESPACIADO Y POSICIÓN (3 campos)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-arrows-angle-expand me-2" style="color: #FF8600;"></i>
Espaciado y Posición
</h5>
<!-- Number inputs compactados: padding_vertical + padding_horizontal -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="letsTalkButtonPaddingVertical" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-vertical me-1" style="color: #FF8600;"></i>
Padding vertical
</label>
<input type="number" id="letsTalkButtonPaddingVertical" class="form-control form-control-sm" value="0.5" min="0" max="3" step="0.1">
<small class="text-muted">En rem (0-3)</small>
</div>
<div class="col-6">
<label for="letsTalkButtonPaddingHorizontal" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-arrows-horizontal me-1" style="color: #FF8600;"></i>
Padding horizontal
</label>
<input type="number" id="letsTalkButtonPaddingHorizontal" class="form-control form-control-sm" value="1.5" min="0" max="5" step="0.1">
<small class="text-muted">En rem (0-5)</small>
</div>
</div>
<!-- Select: position -->
<div class="mb-0">
<label for="letsTalkButtonPosition" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-pin-angle me-1" style="color: #FF8600;"></i>
Posición en navbar
</label>
<select id="letsTalkButtonPosition" class="form-select form-select-sm">
<option value="left">Izquierda</option>
<option value="center">Centro</option>
<option value="right" selected>Derecha</option>
</select>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<!-- ========================================
GRUPO 6: COLORES PERSONALIZADOS (4 campos)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
PATRÓN 5: COLOR PICKERS EN GRID 2X2
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-palette me-2" style="color: #FF8600;"></i>
Colores Personalizados
</h5>
<!-- Color pickers en grid 2x2 -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="letsTalkButtonBgColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Color de fondo
</label>
<input type="color" id="letsTalkButtonBgColor" class="form-control form-control-color w-100" value="#FF8600" title="Seleccionar color de fondo">
<small class="text-muted d-block mt-1" id="letsTalkButtonBgColorValue">#FF8600</small>
</div>
<div class="col-6">
<label for="letsTalkButtonBgHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-cursor me-1" style="color: #FF8600;"></i>
Color hover
</label>
<input type="color" id="letsTalkButtonBgHoverColor" class="form-control form-control-color w-100" value="#FF6B35" title="Seleccionar color hover">
<small class="text-muted d-block mt-1" id="letsTalkButtonBgHoverColorValue">#FF6B35</small>
</div>
</div>
<div class="row g-2 mb-0">
<div class="col-6">
<label for="letsTalkButtonTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type me-1" style="color: #FF8600;"></i>
Color de texto
</label>
<input type="color" id="letsTalkButtonTextColor" class="form-control form-control-color w-100" value="#ffffff" title="Seleccionar color de texto">
<small class="text-muted d-block mt-1" id="letsTalkButtonTextColorValue">#ffffff</small>
</div>
<div class="col-6">
<label for="letsTalkButtonIconColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-star-fill me-1" style="color: #FF8600;"></i>
Color icono
</label>
<input type="color" id="letsTalkButtonIconColor" class="form-control form-control-color w-100" value="#ffffff" title="Seleccionar color icono">
<small class="text-muted d-block mt-1" id="letsTalkButtonIconColorValue">#ffffff</small>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 7: ESTILOS AVANZADOS (8 campos)
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-sliders me-2" style="color: #FF8600;"></i>
Estilos Avanzados
</h5>
<!-- Number inputs compactados: border_radius + border_width -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="letsTalkButtonBorderRadius" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-border-radius me-1" style="color: #FF8600;"></i>
Radio esquinas
</label>
<input type="number" id="letsTalkButtonBorderRadius" class="form-control form-control-sm" value="6" min="0" max="30" step="1">
<small class="text-muted">En px (0-30)</small>
</div>
<div class="col-6">
<label for="letsTalkButtonBorderWidth" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-border-width me-1" style="color: #FF8600;"></i>
Ancho de borde
</label>
<input type="number" id="letsTalkButtonBorderWidth" class="form-control form-control-sm" value="0" min="0" max="10" step="1">
<small class="text-muted">En px (0-10)</small>
</div>
</div>
<!-- Border color + border style compactados -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="letsTalkButtonBorderColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-border-style me-1" style="color: #FF8600;"></i>
Color borde
</label>
<input type="color" id="letsTalkButtonBorderColor" class="form-control form-control-color w-100" value="#000000" title="Seleccionar color borde">
<small class="text-muted d-block mt-1" id="letsTalkButtonBorderColorValue">#000000</small>
</div>
<div class="col-6">
<label for="letsTalkButtonBorderStyle" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-dash-lg me-1" style="color: #FF8600;"></i>
Estilo borde
</label>
<select id="letsTalkButtonBorderStyle" class="form-select form-select-sm">
<option value="solid" selected>Sólido</option>
<option value="dashed">Guiones</option>
<option value="dotted">Puntos</option>
</select>
</div>
</div>
<!-- Switch: enable_box_shadow -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="letsTalkButtonEnableBoxShadow">
<label class="form-check-label small" for="letsTalkButtonEnableBoxShadow" style="color: #495057;">
<i class="bi bi-box-seam me-1" style="color: #FF8600;"></i>
<strong>Habilitar sombra</strong>
</label>
</div>
</div>
<!-- Text input: box_shadow -->
<div class="mb-2">
<label for="letsTalkButtonBoxShadow" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-shadow me-1" style="color: #FF8600;"></i>
CSS box-shadow
</label>
<input type="text" id="letsTalkButtonBoxShadow" class="form-control form-control-sm" value="0 2px 8px rgba(0, 0, 0, 0.15)" maxlength="100">
<small class="text-muted">Ejemplo: 0 4px 12px rgba(255, 134, 0, 0.3)</small>
</div>
<!-- Selects compactados: transition_speed + hover_effect -->
<div class="row g-2 mb-0">
<div class="col-6">
<label for="letsTalkButtonTransitionSpeed" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-speedometer me-1" style="color: #FF8600;"></i>
Velocidad
</label>
<select id="letsTalkButtonTransitionSpeed" class="form-select form-select-sm">
<option value="fast">Rápido (0.2s)</option>
<option value="normal" selected>Normal (0.3s)</option>
<option value="slow">Lento (0.5s)</option>
</select>
</div>
<div class="col-6">
<label for="letsTalkButtonHoverEffect" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-magic me-1" style="color: #FF8600;"></i>
Efecto hover
</label>
<select id="letsTalkButtonHoverEffect" class="form-select form-select-sm">
<option value="none" selected>Ninguno</option>
<option value="scale">Escala (1.05)</option>
<option value="brightness">Brillo</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,585 +0,0 @@
<?php
/**
* Navbar Component - Admin Interface v2.0
* Sigue los 4 patrones obligatorios de Top Bar
*
* @package Apus_Theme
* @since 2.0.0
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
?>
<!-- ============================================================
TAB: NAVBAR CONFIGURATION
============================================================ -->
<div class="tab-pane fade"
id="navbarTab"
role="tabpanel"
aria-labelledby="navbar-config-tab">
<!-- ========================================
PATRÓN 1: HEADER CON GRADIENTE
======================================== -->
<div class="rounded p-4 mb-4 shadow text-white" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h3 class="h4 mb-1 fw-bold">
<i class="bi bi-list me-2" style="color: #FF8600;"></i>
Configuración Navbar
</h3>
<p class="mb-0 small" style="opacity: 0.85;">
Personaliza el menú de navegación principal de tu sitio
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-light" id="resetNavbarDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- ========================================
PATRÓN 2: LAYOUT 2 COLUMNAS
======================================== -->
<div class="row g-3">
<!-- ========================================
COLUMNA IZQUIERDA
======================================== -->
<div class="col-lg-6">
<!-- ========================================
GRUPO 1: ACTIVACIÓN Y VISIBILIDAD
PATRÓN 3: CARD CON BORDER-LEFT NAVY
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>
Activación y Visibilidad
</h5>
<!-- PATRÓN 4: SWITCHES VERTICALES -->
<!-- Enabled -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarEnabled" checked="">
<label class="form-check-label small" for="navbarEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Activar Navbar</strong>
</label>
</div>
</div>
<!-- Show on Mobile -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarShowOnMobile" checked="">
<label class="form-check-label small" for="navbarShowOnMobile" style="color: #495057;">
<i class="bi bi-phone me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>
</label>
</div>
</div>
<!-- Show on Desktop -->
<div class="mb-0">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarShowOnDesktop" checked="">
<label class="form-check-label small" for="navbarShowOnDesktop" style="color: #495057;">
<i class="bi bi-display me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>
</label>
</div>
</div>
<!-- Selects compactados en fila -->
<div class="row g-2 mt-3">
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarPosition" class="text-secondary fw-medium mb-1">
Posición
</label>
<select id="navbarPosition" class="form-select form-select-sm">
<option value="sticky" selected>Sticky (fija al scroll)</option>
<option value="static">Static (normal)</option>
<option value="fixed">Fixed (siempre fija)</option>
</select>
</div>
</div>
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarBreakpoint" class="text-secondary fw-medium mb-1">
Breakpoint
</label>
<select id="navbarBreakpoint" class="form-select form-select-sm">
<option value="sm">SM (576px)</option>
<option value="md">MD (768px)</option>
<option value="lg" selected>LG (992px)</option>
<option value="xl">XL (1200px)</option>
<option value="xxl">XXL (1400px)</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 2: COLORES PERSONALIZADOS
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-palette me-2" style="color: #FF8600;"></i>
Colores Personalizados
</h5>
<!-- 7 colores en grid 2x2 (patrón Top Bar) -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="navbarBgColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Color de fondo
</label>
<input type="color" id="navbarBgColor" class="form-control form-control-color w-100" value="#1e3a5f" title="Seleccionar color de fondo">
<small class="text-muted d-block mt-1" id="navbarBgColorValue">#1e3a5f</small>
</div>
<div class="col-6">
<label for="navbarTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Color de texto
</label>
<input type="color" id="navbarTextColor" class="form-control form-control-color w-100" value="#ffffff" title="Seleccionar color de texto">
<small class="text-muted d-block mt-1" id="navbarTextColorValue">#ffffff</small>
</div>
<div class="col-6">
<label for="navbarLinkHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-cursor me-1" style="color: #FF8600;"></i>
Color hover links
</label>
<input type="color" id="navbarLinkHoverColor" class="form-control form-control-color w-100" value="#FF8600" title="Seleccionar color hover enlaces">
<small class="text-muted d-block mt-1" id="navbarLinkHoverColorValue">#FF8600</small>
</div>
<div class="col-6">
<label for="navbarLinkHoverBgColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-square-fill me-1" style="color: #FF8600;"></i>
Background hover
</label>
<input type="color" id="navbarLinkHoverBgColor" class="form-control form-control-color w-100" value="#FF8600" title="Seleccionar color fondo hover">
<small class="text-muted d-block mt-1" id="navbarLinkHoverBgColorValue">#FF8600</small>
</div>
<div class="col-6">
<label for="navbarDropdownBgColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-card-text me-1" style="color: #FF8600;"></i>
Dropdown BG
</label>
<input type="color" id="navbarDropdownBgColor" class="form-control form-control-color w-100" value="#ffffff" title="Seleccionar color fondo dropdown">
<small class="text-muted d-block mt-1" id="navbarDropdownBgColorValue">#ffffff</small>
</div>
<div class="col-6">
<label for="navbarDropdownItemColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-text-left me-1" style="color: #FF8600;"></i>
Item Color
</label>
<input type="color" id="navbarDropdownItemColor" class="form-control form-control-color w-100" value="#495057" title="Seleccionar color items dropdown">
<small class="text-muted d-block mt-1" id="navbarDropdownItemColorValue">#495057</small>
</div>
<div class="col-6">
<label for="navbarDropdownItemHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-hand-index me-1" style="color: #FF8600;"></i>
Item Hover
</label>
<input type="color" id="navbarDropdownItemHoverColor" class="form-control form-control-color w-100" value="#FF8600" title="Seleccionar color hover items dropdown">
<small class="text-muted d-block mt-1" id="navbarDropdownItemHoverColorValue">#FF8600</small>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 3: TIPOGRAFÍA
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-fonts me-2" style="color: #FF8600;"></i>
Tipografía
</h5>
<div class="row g-2">
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarFontSize" class="text-secondary fw-medium mb-1">
Tamaño de fuente
</label>
<select id="navbarFontSize" class="form-select form-select-sm">
<option value="small">Pequeño (0.8rem)</option>
<option value="normal" selected>Normal (0.9rem)</option>
<option value="large">Grande (1rem)</option>
</select>
</div>
</div>
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarFontWeight" class="text-secondary fw-medium mb-1">
Peso de fuente
</label>
<select id="navbarFontWeight" class="form-select form-select-sm">
<option value="400">Normal (400)</option>
<option value="500" selected>Medium (500)</option>
<option value="600">Semibold (600)</option>
<option value="700">Bold (700)</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 4: EFECTOS VISUALES
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-magic me-2" style="color: #FF8600;"></i>
Efectos Visuales
</h5>
<!-- Switches verticales -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarEnableBoxShadow" checked="">
<label class="form-check-label small" for="navbarEnableBoxShadow" style="color: #495057;">
<i class="bi bi-box-arrow-down me-1" style="color: #FF8600;"></i>
<strong>Habilitar Box Shadow</strong>
</label>
</div>
</div>
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarEnableUnderlineEffect" checked="">
<label class="form-check-label small" for="navbarEnableUnderlineEffect" style="color: #495057;">
<i class="bi bi-dash-lg me-1" style="color: #FF8600;"></i>
<strong>Línea animada al hover</strong>
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarEnableHoverBackground" checked="">
<label class="form-check-label small" for="navbarEnableHoverBackground" style="color: #495057;">
<i class="bi bi-square-fill me-1" style="color: #FF8600;"></i>
<strong>Background al hover</strong>
</label>
</div>
</div>
<!-- Selects y números compactados -->
<div class="row g-2">
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarBoxShadowIntensity" class="text-secondary fw-medium mb-1">
Intensidad sombra
</label>
<select id="navbarBoxShadowIntensity" class="form-select form-select-sm">
<option value="none">Sin sombra</option>
<option value="light">Ligera</option>
<option value="normal" selected>Normal</option>
<option value="strong">Fuerte</option>
</select>
</div>
</div>
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarBorderRadius" class="text-secondary fw-medium mb-1">
Border radius (px)
</label>
<input type="number"
id="navbarBorderRadius"
class="form-control form-control-sm"
value="4"
min="0"
max="20">
</div>
</div>
</div>
</div>
</div>
</div> <!-- Fin columna izquierda -->
<!-- ========================================
COLUMNA DERECHA
======================================== -->
<div class="col-lg-6">
<!-- ========================================
GRUPO 5: SPACING
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-arrows-expand me-2" style="color: #FF8600;"></i>
Espaciado
</h5>
<div class="row g-2">
<div class="col-md-4">
<div class="form-group mb-3">
<label for="navbarPaddingVertical" class="text-secondary fw-medium mb-1">
Padding navbar (rem)
</label>
<input type="number"
id="navbarPaddingVertical"
class="form-control form-control-sm"
value="0.75"
min="0"
max="3"
step="0.05">
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label for="navbarLinkPaddingVertical" class="text-secondary fw-medium mb-1">
Padding links V (rem)
</label>
<input type="number"
id="navbarLinkPaddingVertical"
class="form-control form-control-sm"
value="0.5"
min="0"
max="2"
step="0.05">
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label for="navbarLinkPaddingHorizontal" class="text-secondary fw-medium mb-1">
Padding links H (rem)
</label>
<input type="number"
id="navbarLinkPaddingHorizontal"
class="form-control form-control-sm"
value="0.65"
min="0"
max="2"
step="0.05">
</div>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 6: LET'S TALK BUTTON
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-lightning-charge-fill me-2" style="color: #FF8600;"></i>
Botón "Let's Talk"
</h5>
<!-- Switches verticales -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarLetsTalkEnabled" checked="">
<label class="form-check-label small" for="navbarLetsTalkEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Mostrar botón</strong>
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarLetsTalkShowIcon" checked="">
<label class="form-check-label small" for="navbarLetsTalkShowIcon" style="color: #495057;">
<i class="bi bi-eye me-1" style="color: #FF8600;"></i>
<strong>Mostrar icono</strong>
</label>
</div>
</div>
<!-- Texto e icono -->
<div class="row g-2">
<div class="col-6">
<div class="form-group mb-3">
<label for="navbarLetsTalkText" class="text-secondary fw-medium mb-1">
Texto del botón
</label>
<input type="text"
id="navbarLetsTalkText"
class="form-control form-control-sm"
value="Let's Talk"
maxlength="30"
placeholder="Let's Talk">
</div>
</div>
<div class="col-6">
<div class="form-group mb-3">
<label for="navbarLetsTalkIconClass" class="text-secondary fw-medium mb-1">
Clase del icono
</label>
<input type="text"
id="navbarLetsTalkIconClass"
class="form-control form-control-sm"
value="bi bi-lightning-charge-fill"
maxlength="50"
placeholder="bi bi-lightning-charge-fill">
</div>
</div>
</div>
<!-- Posición -->
<div class="form-group mb-0">
<label for="navbarLetsTalkPosition" class="text-secondary fw-medium mb-1">
Posición dentro del navbar
</label>
<select id="navbarLetsTalkPosition" class="form-select form-select-sm">
<option value="left">Izquierda</option>
<option value="center">Centro</option>
<option value="right" selected>Derecha</option>
</select>
</div>
</div>
</div>
<!-- ========================================
GRUPO 7: DROPDOWN
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-chevron-down me-2" style="color: #FF8600;"></i>
Dropdown
</h5>
<!-- Switch vertical -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="navbarDropdownEnableHoverDesktop" checked="">
<label class="form-check-label small" for="navbarDropdownEnableHoverDesktop" style="color: #495057;">
<i class="bi bi-cursor me-1" style="color: #FF8600;"></i>
<strong>Activar al hover (desktop)</strong>
</label>
</div>
</div>
<!-- Números y selects -->
<div class="row g-2">
<div class="col-6">
<div class="form-group mb-3">
<label for="navbarDropdownMaxHeight" class="text-secondary fw-medium mb-1">
Altura máxima (vh)
</label>
<input type="number"
id="navbarDropdownMaxHeight"
class="form-control form-control-sm"
value="70"
min="30"
max="90">
</div>
</div>
<div class="col-6">
<div class="form-group mb-3">
<label for="navbarDropdownBorderRadius" class="text-secondary fw-medium mb-1">
Border radius (px)
</label>
<input type="number"
id="navbarDropdownBorderRadius"
class="form-control form-control-sm"
value="8"
min="0"
max="20">
</div>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarDropdownItemPaddingVertical" class="text-secondary fw-medium mb-1">
Padding items V (rem)
</label>
<input type="number"
id="navbarDropdownItemPaddingVertical"
class="form-control form-control-sm"
value="0.5"
min="0"
max="2"
step="0.05">
</div>
</div>
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarDropdownItemPaddingHorizontal" class="text-secondary fw-medium mb-1">
Padding items H (rem)
</label>
<input type="number"
id="navbarDropdownItemPaddingHorizontal"
class="form-control form-control-sm"
value="1.25"
min="0"
max="3"
step="0.05">
</div>
</div>
</div>
</div>
</div>
<!-- ========================================
GRUPO 8: AVANZADO (OPCIONAL)
======================================== -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-gear me-2" style="color: #FF8600;"></i>
Avanzado
</h5>
<div class="row g-2">
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarZIndex" class="text-secondary fw-medium mb-1">
Z-index
</label>
<input type="number"
id="navbarZIndex"
class="form-control form-control-sm"
value="1030"
min="0"
max="9999">
</div>
</div>
<div class="col-6">
<div class="form-group mb-0">
<label for="navbarTransitionSpeed" class="text-secondary fw-medium mb-1">
Velocidad transiciones
</label>
<select id="navbarTransitionSpeed" class="form-select form-select-sm">
<option value="fast">Rápida (0.2s)</option>
<option value="normal" selected>Normal (0.3s)</option>
<option value="slow">Lenta (0.5s)</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div> <!-- Fin columna derecha -->
</div> <!-- Fin row g-3 -->
</div> <!-- Fin tab-pane -->

View File

@@ -1,237 +0,0 @@
<?php
/**
* Admin Component: Top Bar Configuration
*
* @package Apus_Theme
* @subpackage Admin_Panel
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
?>
<div class="tab-pane fade show active" id="topBarTab" role="tabpanel">
<!-- Header del Tab -->
<div class="rounded p-4 mb-4 shadow text-white" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h3 class="h4 mb-1 fw-bold">
<i class="bi bi-megaphone-fill me-2" style="color: #FF8600;"></i>
Configuración Top Bar
</h3>
<p class="mb-0 small" style="opacity: 0.85;">
Personaliza la barra de anuncios superior de tu sitio
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-light" id="resetTopBarDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- Grid: 2 columnas + 1 fila completa -->
<div class="row g-3">
<!-- COLUMNA IZQUIERDA -->
<div class="col-lg-6">
<!-- GRUPO 1: ACTIVACIÓN -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>
Activación y Visibilidad
</h5>
<!-- Enabled -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarEnabled" checked="">
<label class="form-check-label small" for="topBarEnabled" style="color: #495057;">
<i class="bi bi-power me-1" style="color: #FF8600;"></i>
<strong>Activar Top Bar</strong>
</label>
</div>
</div>
<!-- Show on Mobile -->
<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarShowOnMobile" checked="">
<label class="form-check-label small" for="topBarShowOnMobile" style="color: #495057;">
<i class="bi bi-phone me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Mobile</strong> <span class="text-muted">(&lt;768px)</span>
</label>
</div>
</div>
<!-- Show on Desktop -->
<div class="mb-0">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarShowOnDesktop" checked="">
<label class="form-check-label small" for="topBarShowOnDesktop" style="color: #495057;">
<i class="bi bi-display me-1" style="color: #FF8600;"></i>
<strong>Mostrar en Desktop</strong> <span class="text-muted">(≥768px)</span>
</label>
</div>
</div>
</div>
</div>
<!-- GRUPO 2: ESTILOS -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-palette me-2" style="color: #FF8600;"></i>
Estilos Personalizados
</h5>
<!-- 4 colores en grid 2x2 -->
<div class="row g-2 mb-2">
<div class="col-6">
<label for="topBarBgColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-paint-bucket me-1" style="color: #FF8600;"></i>
Color de fondo
</label>
<input type="color" id="topBarBgColor" class="form-control form-control-color w-100" value="#0E2337" title="Seleccionar color de fondo">
<small class="text-muted d-block mt-1" id="topBarBgColorValue">#0E2337</small>
</div>
<div class="col-6">
<label for="topBarTextColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-fonts me-1" style="color: #FF8600;"></i>
Color de texto
</label>
<input type="color" id="topBarTextColor" class="form-control form-control-color w-100" value="#ffffff" title="Seleccionar color de texto">
<small class="text-muted d-block mt-1" id="topBarTextColorValue">#FFFFFF</small>
</div>
<div class="col-6">
<label for="topBarHighlightColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-star me-1" style="color: #FF8600;"></i>
Color destacado
</label>
<input type="color" id="topBarHighlightColor" class="form-control form-control-color w-100" value="#FF8600" title="Seleccionar color destacado">
<small class="text-muted d-block mt-1" id="topBarHighlightColorValue">#FF8600</small>
</div>
<div class="col-6">
<label for="topBarLinkHoverColor" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-cursor me-1" style="color: #FF8600;"></i>
Hover enlace
</label>
<input type="color" id="topBarLinkHoverColor" class="form-control form-control-color w-100" value="#FF6B35" title="Seleccionar color hover del enlace">
<small class="text-muted d-block mt-1" id="topBarLinkHoverColorValue">#FF6B35</small>
</div>
</div>
<!-- Tamaño de fuente -->
<div class="mb-0">
<label for="topBarFontSize" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-type me-1" style="color: #FF8600;"></i>
Tamaño de fuente
</label>
<select id="topBarFontSize" class="form-select form-select-sm">
<option value="small">Pequeño (0.8rem)</option>
<option value="normal" selected="">Normal (0.9rem)</option>
<option value="large">Grande (1rem)</option>
</select>
</div>
</div>
</div>
</div>
<!-- COLUMNA DERECHA -->
<div class="col-lg-6">
<!-- GRUPO 3: CONTENIDO -->
<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #1e3a5f;">
<i class="bi bi-card-text me-2" style="color: #FF8600;"></i>
Contenido y Mensajes
</h5>
<!-- Icono + mostrar -->
<div class="row g-2 mb-2">
<div class="col-8">
<label for="topBarIconClass" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-emoji-smile me-1" style="color: #FF8600;"></i>
Clase del icono <span class="badge bg-secondary" style="font-size: 0.65rem;">Bootstrap Icons</span>
</label>
<input type="text" id="topBarIconClass" class="form-control form-control-sm" placeholder="bi bi-megaphone-fill" value="bi bi-megaphone-fill" maxlength="50">
<small class="text-muted d-block mt-1">
<i class="bi bi-info-circle me-1"></i>
Ver: <a href="https://icons.getbootstrap.com/" target="_blank" class="text-decoration-none" style="color: #FF8600;">Bootstrap Icons <i class="bi bi-box-arrow-up-right"></i></a>
</small>
</div>
<div class="col-4">
<label class="form-label small mb-1 fw-semibold" style="color: #495057;">Opciones</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="topBarShowIcon" checked="">
<label class="form-check-label small" for="topBarShowIcon" style="color: #495057;">Mostrar</label>
</div>
</div>
</div>
<!-- Texto destacado -->
<div class="mb-2">
<label for="topBarHighlightText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-bookmark-star me-1" style="color: #FF8600;"></i>
Texto destacado <span class="badge text-dark" style="background-color: #FFB800; font-size: 0.65rem;">Opcional</span>
</label>
<input type="text" id="topBarHighlightText" class="form-control form-control-sm" placeholder="Ej: &quot;Nuevo:&quot; o &quot;Promoción:&quot;" value="Nuevo:" maxlength="30">
</div>
<!-- Mensaje principal -->
<div class="mb-2">
<label for="topBarMessageText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-chat-left-text me-1" style="color: #FF8600;"></i>
Mensaje principal <span class="text-danger">*</span>
<span class="float-end text-muted"><span id="topBarMessageTextCount" class="fw-bold">77</span>/250</span>
</label>
<textarea id="topBarMessageText" class="form-control form-control-sm" rows="2" maxlength="250" placeholder="Ej: Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025." required="">Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</textarea>
<div class="progress mt-1" style="height: 3px;">
<div id="topBarMessageTextProgress" class="progress-bar bg-orange-primary" role="progressbar" style="width: 30.8%; background-color: rgb(255, 134, 0);" aria-valuenow="77" aria-valuemin="0" aria-valuemax="250"></div>
</div>
</div>
<!-- Enlace (3 campos compactos) -->
<div class="row g-2 mb-2">
<div class="col-5">
<label for="topBarLinkText" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>
Texto enlace
</label>
<input type="text" id="topBarLinkText" class="form-control form-control-sm" placeholder="Ver Catálogo" value="Ver Catálogo →" maxlength="50">
</div>
<div class="col-5">
<label for="topBarLinkUrl" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-globe me-1" style="color: #FF8600;"></i>
URL
</label>
<input type="url" id="topBarLinkUrl" class="form-control form-control-sm" placeholder="/catalogo" value="/catalogo">
</div>
<div class="col-2">
<label for="topBarLinkTarget" class="form-label small mb-1 fw-semibold" style="color: #495057;">
<i class="bi bi-window me-1" style="color: #FF8600;"></i>
Target
</label>
<select id="topBarLinkTarget" class="form-select form-select-sm">
<option value="_self" selected="">_self</option>
<option value="_blank">_blank</option>
</select>
</div>
</div>
<div class="mb-0">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="topBarShowLink" checked="">
<label class="form-check-label small" for="topBarShowLink" style="color: #495057;">
<strong>Mostrar enlace</strong>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,136 +0,0 @@
<?php
/**
* Admin Menu Class
*
* Registra menú en WordPress admin y carga assets
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class APUS_Admin_Menu {
/**
* Constructor
*/
public function __construct() {
add_action('admin_menu', array($this, 'add_menu_page'));
add_action('admin_enqueue_scripts', array($this, 'enqueue_assets'));
}
/**
* Registrar página de admin
*/
public function add_menu_page() {
add_theme_page(
'APUs Theme Settings', // Page title
'Tema APUs', // Menu title
'manage_options', // Capability
'apus-theme-settings', // Menu slug
array($this, 'render_admin_page'), // Callback
59 // Position
);
}
/**
* Renderizar página de admin
*/
public function render_admin_page() {
if (!current_user_can('manage_options')) {
wp_die(__('No tienes permisos para acceder a esta página.'));
}
require_once APUS_ADMIN_PANEL_PATH . 'pages/main.php';
}
/**
* Encolar assets (CSS/JS)
*/
public function enqueue_assets($hook) {
// Solo cargar en nuestra página
if ($hook !== 'appearance_page_apus-theme-settings') {
return;
}
// Bootstrap 5.3.2 CSS
wp_enqueue_style(
'bootstrap',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css',
array(),
'5.3.2'
);
// Bootstrap Icons
wp_enqueue_style(
'bootstrap-icons',
'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css',
array(),
'1.11.1'
);
// Admin Panel CSS (Core)
wp_enqueue_style(
'apus-admin-panel-css',
APUS_ADMIN_PANEL_URL . 'assets/css/admin-panel.css',
array('bootstrap'),
APUS_ADMIN_PANEL_VERSION
);
// Component: Navbar CSS (estilos admin específicos)
wp_enqueue_style(
'apus-component-navbar-css',
APUS_ADMIN_PANEL_URL . 'assets/css/component-navbar.css',
array('apus-admin-panel-css'),
APUS_ADMIN_PANEL_VERSION
);
// Bootstrap 5.3.2 JS
wp_enqueue_script(
'bootstrap',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',
array(),
'5.3.2',
true
);
// Axios (para AJAX)
wp_enqueue_script(
'axios',
'https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js',
array(),
'1.6.0',
true
);
// Component: Navbar JS (cargar antes de admin-app.js)
wp_enqueue_script(
'apus-component-navbar-js',
APUS_ADMIN_PANEL_URL . 'assets/js/component-navbar.js',
array('jquery'),
APUS_ADMIN_PANEL_VERSION,
true
);
// Admin Panel JS (Core - depende de componentes)
wp_enqueue_script(
'apus-admin-panel-js',
APUS_ADMIN_PANEL_URL . 'assets/js/admin-app.js',
array('jquery', 'axios', 'apus-component-navbar-js'),
APUS_ADMIN_PANEL_VERSION,
true
);
// Pasar datos a JavaScript
wp_localize_script('apus-admin-panel-js', 'apusAdminData', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('apus_admin_nonce')
));
}
}
// Instanciar clase
new APUS_Admin_Menu();

View File

@@ -1,310 +0,0 @@
<?php
/**
* Data Migrator Class
*
* Migración de datos de wp_options a tabla personalizada
*
* @package Apus_Theme
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
class APUS_Data_Migrator {
/**
* Opción para trackear si la migración se completó
*/
const MIGRATION_FLAG = 'apus_data_migrated';
/**
* Opción antigua en wp_options
*/
const OLD_OPTION_NAME = 'apus_theme_settings';
/**
* DB Manager instance
*/
private $db_manager;
/**
* Constructor
*/
public function __construct() {
$this->db_manager = new APUS_DB_Manager();
}
/**
* Verificar si la migración ya se ejecutó
*/
public function is_migrated() {
return get_option(self::MIGRATION_FLAG) === '1';
}
/**
* Ejecutar migración si es necesaria
*/
public function maybe_migrate() {
if ($this->is_migrated()) {
return array(
'success' => true,
'message' => 'La migración ya fue ejecutada anteriormente'
);
}
if (!$this->db_manager->table_exists()) {
return array(
'success' => false,
'message' => 'La tabla de destino no existe'
);
}
return $this->migrate();
}
/**
* Ejecutar migración completa
*/
public function migrate() {
global $wpdb;
// Comenzar transacción
$wpdb->query('START TRANSACTION');
try {
// Obtener datos de wp_options
$old_data = get_option(self::OLD_OPTION_NAME);
if (empty($old_data)) {
throw new Exception('No hay datos para migrar en wp_options');
}
$total_migrated = 0;
// Verificar estructura de datos
if (!isset($old_data['components']) || !is_array($old_data['components'])) {
throw new Exception('Estructura de datos inválida');
}
// Obtener versión y timestamp
$version = isset($old_data['version']) ? $old_data['version'] : APUS_ADMIN_PANEL_VERSION;
// Migrar cada componente
foreach ($old_data['components'] as $component_name => $component_data) {
if (!is_array($component_data)) {
continue;
}
$migrated = $this->migrate_component($component_name, $component_data, $version);
$total_migrated += $migrated;
}
// Marcar migración como completada
update_option(self::MIGRATION_FLAG, '1', false);
// Commit transacción
$wpdb->query('COMMIT');
error_log("APUS Data Migrator: Migración completada. Total de registros: $total_migrated");
return array(
'success' => true,
'message' => 'Migración completada exitosamente',
'total_migrated' => $total_migrated
);
} catch (Exception $e) {
// Rollback en caso de error
$wpdb->query('ROLLBACK');
error_log("APUS Data Migrator: Error en migración - " . $e->getMessage());
return array(
'success' => false,
'message' => 'Error en migración: ' . $e->getMessage()
);
}
}
/**
* Migrar un componente específico
*
* @param string $component_name Nombre del componente
* @param array $component_data Datos del componente
* @param string $version Versión
* @return int Número de registros migrados
*/
private function migrate_component($component_name, $component_data, $version) {
$count = 0;
foreach ($component_data as $key => $value) {
// Determinar tipo de dato
$data_type = $this->determine_data_type($key, $value);
// Si es un array/objeto anidado (como custom_styles), guardarlo como JSON
if ($data_type === 'json') {
$result = $this->db_manager->save_config(
$component_name,
$key,
$value,
$data_type,
$version
);
} else {
$result = $this->db_manager->save_config(
$component_name,
$key,
$value,
$data_type,
$version
);
}
if ($result !== false) {
$count++;
}
}
return $count;
}
/**
* Determinar el tipo de dato
*
* @param string $key Clave de configuración
* @param mixed $value Valor
* @return string Tipo de dato (string, boolean, integer, json)
*/
private function determine_data_type($key, $value) {
if (is_bool($value)) {
return 'boolean';
}
if (is_int($value)) {
return 'integer';
}
if (is_array($value)) {
return 'json';
}
// Por nombre de clave
if (in_array($key, array('enabled', 'show_on_mobile', 'show_on_desktop', 'show_icon', 'show_link'))) {
return 'boolean';
}
return 'string';
}
/**
* Crear backup de datos antiguos
*
* @return bool Éxito de la operación
*/
public function backup_old_data() {
$old_data = get_option(self::OLD_OPTION_NAME);
if (empty($old_data)) {
return false;
}
$backup_option = self::OLD_OPTION_NAME . '_backup_' . time();
return update_option($backup_option, $old_data, false);
}
/**
* Restaurar desde backup (rollback)
*
* @param string $backup_option Nombre de la opción de backup
* @return bool Éxito de la operación
*/
public function rollback($backup_option) {
$backup_data = get_option($backup_option);
if (empty($backup_data)) {
return false;
}
// Restaurar datos antiguos
update_option(self::OLD_OPTION_NAME, $backup_data, false);
// Limpiar flag de migración
delete_option(self::MIGRATION_FLAG);
// Limpiar tabla personalizada
global $wpdb;
$table_name = $this->db_manager->get_table_name();
$wpdb->query("TRUNCATE TABLE $table_name");
return true;
}
/**
* Comparar datos entre wp_options y tabla personalizada
*
* @return array Resultado de la comparación
*/
public function verify_migration() {
$old_data = get_option(self::OLD_OPTION_NAME);
if (empty($old_data) || !isset($old_data['components'])) {
return array(
'success' => false,
'message' => 'No hay datos en wp_options para comparar'
);
}
$discrepancies = array();
foreach ($old_data['components'] as $component_name => $component_data) {
$new_data = $this->db_manager->get_config($component_name);
foreach ($component_data as $key => $old_value) {
$new_value = isset($new_data[$key]) ? $new_data[$key] : null;
// Comparar valores (teniendo en cuenta conversiones de tipo)
if ($this->normalize_value($old_value) !== $this->normalize_value($new_value)) {
$discrepancies[] = array(
'component' => $component_name,
'key' => $key,
'old_value' => $old_value,
'new_value' => $new_value
);
}
}
}
if (empty($discrepancies)) {
return array(
'success' => true,
'message' => 'Migración verificada: todos los datos coinciden'
);
}
return array(
'success' => false,
'message' => 'Se encontraron discrepancias en la migración',
'discrepancies' => $discrepancies
);
}
/**
* Normalizar valor para comparación
*
* @param mixed $value Valor a normalizar
* @return mixed Valor normalizado
*/
private function normalize_value($value) {
if (is_bool($value)) {
return $value ? 1 : 0;
}
if (is_array($value)) {
return json_encode($value);
}
return $value;
}
}

View File

@@ -1,251 +0,0 @@
<?php
/**
* Database Manager Class
*
* Gestión de tablas personalizadas del tema
*
* @package Apus_Theme
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
class APUS_DB_Manager {
/**
* Nombre de la tabla de componentes (sin prefijo)
*/
const TABLE_COMPONENTS = 'apus_theme_components';
/**
* Versión de la base de datos
*/
const DB_VERSION = '1.0';
/**
* Opción para almacenar la versión de la DB
*/
const DB_VERSION_OPTION = 'apus_db_version';
/**
* Constructor
*/
public function __construct() {
// Hook para verificar/actualizar DB en cada carga
add_action('admin_init', array($this, 'maybe_create_tables'));
}
/**
* Obtener nombre completo de tabla con prefijo
*/
public function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_COMPONENTS;
}
/**
* Verificar si las tablas necesitan ser creadas o actualizadas
*/
public function maybe_create_tables() {
$installed_version = get_option(self::DB_VERSION_OPTION);
if ($installed_version !== self::DB_VERSION) {
$this->create_tables();
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
}
/**
* Crear tablas personalizadas
*/
public function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$table_name = $this->get_table_name();
$sql = "CREATE TABLE $table_name (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
component_name VARCHAR(50) NOT NULL,
config_key VARCHAR(100) NOT NULL,
config_value TEXT NOT NULL,
data_type ENUM('string', 'boolean', 'integer', 'json') DEFAULT 'string',
version VARCHAR(10) DEFAULT NULL,
updated_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY component_config (component_name, config_key),
INDEX idx_component (component_name),
INDEX idx_updated (updated_at)
) $charset_collate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
// Verificar si la tabla se creó correctamente
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name) {
error_log("APUS DB Manager: Tabla $table_name creada/actualizada exitosamente");
return true;
} else {
error_log("APUS DB Manager: Error al crear tabla $table_name");
return false;
}
}
/**
* Verificar si una tabla existe
*/
public function table_exists() {
global $wpdb;
$table_name = $this->get_table_name();
return $wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name;
}
/**
* Guardar configuración de un componente
*
* @param string $component_name Nombre del componente
* @param string $config_key Clave de configuración
* @param mixed $config_value Valor de configuración
* @param string $data_type Tipo de dato (string, boolean, integer, json)
* @param string $version Versión del tema
* @return bool|int ID del registro o false en caso de error
*/
public function save_config($component_name, $config_key, $config_value, $data_type = 'string', $version = null) {
global $wpdb;
$table_name = $this->get_table_name();
// Convertir valor según tipo
if ($data_type === 'json' && is_array($config_value)) {
$config_value = json_encode($config_value, JSON_UNESCAPED_UNICODE);
} elseif ($data_type === 'boolean') {
$config_value = $config_value ? '1' : '0';
}
// Usar ON DUPLICATE KEY UPDATE para INSERT o UPDATE
$result = $wpdb->query($wpdb->prepare(
"INSERT INTO $table_name (component_name, config_key, config_value, data_type, version, updated_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
data_type = VALUES(data_type),
version = VALUES(version),
updated_at = VALUES(updated_at)",
$component_name,
$config_key,
$config_value,
$data_type,
$version,
current_time('mysql')
));
return $result !== false ? $wpdb->insert_id : false;
}
/**
* Obtener configuración de un componente
*
* @param string $component_name Nombre del componente
* @param string $config_key Clave específica (opcional)
* @return array|mixed Configuración completa o valor específico
*/
public function get_config($component_name, $config_key = null) {
global $wpdb;
$table_name = $this->get_table_name();
if ($config_key !== null) {
// Obtener un valor específico
$row = $wpdb->get_row($wpdb->prepare(
"SELECT config_value, data_type FROM $table_name
WHERE component_name = %s AND config_key = %s",
$component_name,
$config_key
));
if ($row) {
return $this->parse_value($row->config_value, $row->data_type);
}
return null;
}
// Obtener toda la configuración del componente
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT config_key, config_value, data_type FROM $table_name
WHERE component_name = %s",
$component_name
));
$config = array();
foreach ($rows as $row) {
$config[$row->config_key] = $this->parse_value($row->config_value, $row->data_type);
}
return $config;
}
/**
* Parsear valor según tipo de dato
*
* @param string $value Valor almacenado
* @param string $data_type Tipo de dato
* @return mixed Valor parseado
*/
private function parse_value($value, $data_type) {
switch ($data_type) {
case 'boolean':
return (bool) $value;
case 'integer':
return (int) $value;
case 'json':
return json_decode($value, true);
default:
return $value;
}
}
/**
* Eliminar configuraciones de un componente
*
* @param string $component_name Nombre del componente
* @param string $config_key Clave específica (opcional)
* @return bool Éxito de la operación
*/
public function delete_config($component_name, $config_key = null) {
global $wpdb;
$table_name = $this->get_table_name();
if ($config_key !== null) {
return $wpdb->delete(
$table_name,
array(
'component_name' => $component_name,
'config_key' => $config_key
),
array('%s', '%s')
) !== false;
}
// Eliminar todas las configuraciones del componente
return $wpdb->delete(
$table_name,
array('component_name' => $component_name),
array('%s')
) !== false;
}
/**
* Listar todos los componentes con configuraciones
*
* @return array Lista de nombres de componentes
*/
public function list_components() {
global $wpdb;
$table_name = $this->get_table_name();
return $wpdb->get_col(
"SELECT DISTINCT component_name FROM $table_name ORDER BY component_name"
);
}
}

View File

@@ -1,195 +0,0 @@
<?php
/**
* Settings Manager Class
*
* CRUD de configuraciones por componentes
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class APUS_Settings_Manager {
const OPTION_NAME = 'apus_theme_settings';
/**
* Constructor
*/
public function __construct() {
add_action('wp_ajax_apus_get_settings', array($this, 'ajax_get_settings'));
add_action('wp_ajax_apus_save_settings', array($this, 'ajax_save_settings'));
}
/**
* Obtener configuraciones
*/
public function get_settings() {
$settings = get_option(self::OPTION_NAME, array());
$defaults = $this->get_defaults();
return wp_parse_args($settings, $defaults);
}
/**
* Guardar configuraciones
*/
public function save_settings($data) {
// Validar
$validator = new APUS_Validator();
$validation = $validator->validate($data);
if (!$validation['valid']) {
return array(
'success' => false,
'message' => 'Error de validación',
'errors' => $validation['errors']
);
}
// Sanitizar
$sanitized = $this->sanitize_settings($data);
// Agregar metadata
$sanitized['version'] = APUS_ADMIN_PANEL_VERSION;
$sanitized['updated_at'] = current_time('mysql');
// Guardar
update_option(self::OPTION_NAME, $sanitized, false);
return array(
'success' => true,
'message' => 'Configuración guardada correctamente'
);
}
/**
* Valores por defecto
* NOTA: Aquí se agregan los defaults de cada componente
*/
public function get_defaults() {
return array(
'version' => APUS_ADMIN_PANEL_VERSION,
'components' => array(
'top_bar' => array(
'enabled' => true,
'show_on_mobile' => true,
'show_on_desktop' => true,
'icon_class' => 'bi bi-megaphone-fill',
'show_icon' => true,
'highlight_text' => 'Nuevo:',
'message_text' => 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.',
'link_text' => 'Ver Catálogo',
'link_url' => '/catalogo',
'link_target' => '_self',
'show_link' => true,
'custom_styles' => array(
// Valores extraídos de componente-top-bar.css
'background_color' => '#0E2337', // var(--color-navy-dark)
'text_color' => '#ffffff',
'highlight_color' => '#FF8600', // var(--color-orange-primary)
'link_hover_color' => '#FF8600', // var(--color-orange-primary)
'font_size' => 'normal' // 0.9rem del CSS
)
)
// Navbar - Pendiente
// Hero - Pendiente
// Footer - Pendiente
)
);
}
/**
* Sanitizar configuraciones
* NOTA: Aquí se agrega sanitización de cada componente
*/
public function sanitize_settings($data) {
$sanitized = array(
'components' => array()
);
// Sanitizar Top Bar
if (isset($data['components']['top_bar'])) {
$top_bar = $data['components']['top_bar'];
$sanitized['components']['top_bar'] = array(
'enabled' => !empty($top_bar['enabled']),
'show_on_mobile' => !empty($top_bar['show_on_mobile']),
'show_on_desktop' => !empty($top_bar['show_on_desktop']),
'icon_class' => sanitize_text_field($top_bar['icon_class'] ?? ''),
'show_icon' => !empty($top_bar['show_icon']),
'highlight_text' => sanitize_text_field($top_bar['highlight_text'] ?? ''),
'message_text' => sanitize_text_field($top_bar['message_text'] ?? ''),
'link_text' => sanitize_text_field($top_bar['link_text'] ?? ''),
'link_url' => esc_url_raw($top_bar['link_url'] ?? ''),
'link_target' => in_array($top_bar['link_target'] ?? '', array('_self', '_blank')) ? $top_bar['link_target'] : '_self',
'show_link' => !empty($top_bar['show_link']),
'custom_styles' => array(
'background_color' => sanitize_hex_color($top_bar['custom_styles']['background_color'] ?? ''),
'text_color' => sanitize_hex_color($top_bar['custom_styles']['text_color'] ?? ''),
'highlight_color' => sanitize_hex_color($top_bar['custom_styles']['highlight_color'] ?? ''),
'link_hover_color' => sanitize_hex_color($top_bar['custom_styles']['link_hover_color'] ?? ''),
'font_size' => in_array($top_bar['custom_styles']['font_size'] ?? '', array('small', 'normal', 'large')) ? $top_bar['custom_styles']['font_size'] : 'normal'
)
);
}
return $sanitized;
}
/**
* AJAX: Obtener configuraciones
*/
public function ajax_get_settings() {
// Verificar nonce usando check_ajax_referer (método recomendado para AJAX)
check_ajax_referer('apus_admin_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Permisos insuficientes');
}
$settings = $this->get_settings();
wp_send_json_success($settings);
}
/**
* AJAX: Guardar configuraciones
*/
public function ajax_save_settings() {
// Verificar nonce usando check_ajax_referer (método recomendado para AJAX)
check_ajax_referer('apus_admin_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Permisos insuficientes');
}
// Los datos vienen como JSON string en $_POST['components']
if (!isset($_POST['components'])) {
wp_send_json_error('Datos inválidos - falta components');
}
$components = json_decode(stripslashes($_POST['components']), true);
if (!is_array($components)) {
wp_send_json_error('Datos inválidos - components no es un array válido');
}
$data = array(
'components' => $components
);
$result = $this->save_settings($data);
if ($result['success']) {
wp_send_json_success($result);
} else {
wp_send_json_error($result);
}
}
}
// Instanciar clase
new APUS_Settings_Manager();

View File

@@ -1,382 +0,0 @@
<?php
/**
* Theme Options Migrator Class
*
* Migra configuraciones de wp_options a tabla personalizada wp_apus_theme_components
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class APUS_Theme_Options_Migrator {
/**
* DB Manager instance
*/
private $db_manager;
/**
* Nombre de la opción en wp_options
*/
const OLD_OPTION_NAME = 'apus_theme_options';
/**
* Nombre del componente en la nueva tabla
*/
const COMPONENT_NAME = 'theme';
/**
* Constructor
*/
public function __construct() {
$this->db_manager = new APUS_DB_Manager();
}
/**
* Mapeo de tipos de datos para cada configuración
*
* @return array Mapeo config_key => data_type
*/
private function get_data_types_map() {
return array(
// Integers (IDs y contadores)
'site_logo' => 'integer',
'site_favicon' => 'integer',
'excerpt_length' => 'integer',
'archive_posts_per_page' => 'integer',
'related_posts_count' => 'integer',
'related_posts_columns' => 'integer',
// Booleans (enable_*, show_*, performance_*)
'enable_breadcrumbs' => 'boolean',
'show_featured_image_single' => 'boolean',
'show_author_box' => 'boolean',
'enable_comments_posts' => 'boolean',
'enable_comments_pages' => 'boolean',
'show_post_meta' => 'boolean',
'show_post_tags' => 'boolean',
'show_post_categories' => 'boolean',
'enable_lazy_loading' => 'boolean',
'performance_remove_emoji' => 'boolean',
'performance_remove_embeds' => 'boolean',
'performance_remove_dashicons' => 'boolean',
'performance_defer_js' => 'boolean',
'performance_minify_html' => 'boolean',
'performance_disable_gutenberg' => 'boolean',
'enable_related_posts' => 'boolean',
// Strings (todo lo demás: URLs, textos cortos, formatos, CSS/JS)
// No es necesario especificarlos, 'string' es el default
);
}
/**
* Determinar tipo de dato para una configuración
*
* @param string $config_key Nombre de la configuración
* @param mixed $config_value Valor de la configuración
* @return string Tipo de dato (string, boolean, integer, json)
*/
private function determine_data_type($config_key, $config_value) {
$types_map = $this->get_data_types_map();
// Si está en el mapa explícito, usar ese tipo
if (isset($types_map[$config_key])) {
return $types_map[$config_key];
}
// Detección automática por valor
if (is_array($config_value)) {
return 'json';
}
if (is_bool($config_value)) {
return 'boolean';
}
if (is_int($config_value)) {
return 'integer';
}
// Default: string (incluye textos largos, URLs, etc.)
return 'string';
}
/**
* Normalizar valor según tipo de dato
*
* @param mixed $value Valor a normalizar
* @param string $data_type Tipo de dato
* @return mixed Valor normalizado
*/
private function normalize_value($value, $data_type) {
switch ($data_type) {
case 'boolean':
// Convertir a booleano real (maneja strings '0', '1', etc.)
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
case 'integer':
return (int) $value;
case 'json':
// Si ya es array, dejarlo así (DB Manager lo codificará)
return is_array($value) ? $value : json_decode($value, true);
case 'string':
default:
return (string) $value;
}
}
/**
* Verificar si ya se realizó la migración
*
* @return bool True si ya está migrado, false si no
*/
public function is_migrated() {
// La migración se considera completa si:
// 1. No existe la opción antigua en wp_options
// 2. Y existen configuraciones en la tabla nueva
$old_options = get_option(self::OLD_OPTION_NAME, false);
$new_config = $this->db_manager->get_config(self::COMPONENT_NAME);
// Si no hay opción antigua Y hay configuraciones nuevas = migrado
return ($old_options === false && !empty($new_config));
}
/**
* Ejecutar migración completa
*
* @return array Resultado de la migración con éxito, mensaje y detalles
*/
public function migrate() {
// 1. Verificar si ya se migró
if ($this->is_migrated()) {
return array(
'success' => false,
'message' => 'La migración ya fue realizada anteriormente',
'already_migrated' => true
);
}
// 2. Obtener configuraciones actuales de wp_options
$old_options = get_option(self::OLD_OPTION_NAME, array());
if (empty($old_options)) {
return array(
'success' => false,
'message' => 'No hay opciones para migrar en wp_options'
);
}
// 3. Crear backup antes de migrar
$backup_result = $this->create_backup($old_options);
if (!$backup_result['success']) {
return $backup_result;
}
$backup_name = $backup_result['backup_name'];
// 4. Migrar cada configuración
$total = count($old_options);
$migrated = 0;
$errors = array();
foreach ($old_options as $config_key => $config_value) {
// Determinar tipo de dato
$data_type = $this->determine_data_type($config_key, $config_value);
// Normalizar valor
$normalized_value = $this->normalize_value($config_value, $data_type);
// Guardar en tabla personalizada
$result = $this->db_manager->save_config(
self::COMPONENT_NAME,
$config_key,
$normalized_value,
$data_type,
APUS_ADMIN_PANEL_VERSION
);
if ($result !== false) {
$migrated++;
} else {
$errors[] = $config_key;
}
}
// 5. Verificar resultado de la migración
if ($migrated === $total) {
// Éxito total
// Eliminar opción antigua de wp_options
delete_option(self::OLD_OPTION_NAME);
return array(
'success' => true,
'message' => sprintf('Migradas %d configuraciones correctamente', $migrated),
'migrated' => $migrated,
'total' => $total,
'backup_name' => $backup_name
);
} else {
// Migración parcial o con errores
return array(
'success' => false,
'message' => sprintf('Solo se migraron %d de %d configuraciones', $migrated, $total),
'migrated' => $migrated,
'total' => $total,
'errors' => $errors,
'backup_name' => $backup_name
);
}
}
/**
* Crear backup de las opciones actuales
*
* @param array $options Opciones a respaldar
* @return array Resultado con success y backup_name
*/
private function create_backup($options) {
$backup_name = self::OLD_OPTION_NAME . '_backup_' . date('Y-m-d_H-i-s');
$result = update_option($backup_name, $options, false); // No autoload
if ($result) {
return array(
'success' => true,
'backup_name' => $backup_name
);
} else {
return array(
'success' => false,
'message' => 'No se pudo crear el backup de seguridad'
);
}
}
/**
* Rollback de migración (revertir a estado anterior)
*
* @param string $backup_name Nombre del backup a restaurar
* @return array Resultado del rollback
*/
public function rollback($backup_name = null) {
// Si no se especifica backup, buscar el más reciente
if ($backup_name === null) {
$backup_name = $this->find_latest_backup();
}
if ($backup_name === null) {
return array(
'success' => false,
'message' => 'No se encontró backup para restaurar'
);
}
// Obtener backup
$backup = get_option($backup_name, false);
if ($backup === false) {
return array(
'success' => false,
'message' => sprintf('Backup "%s" no encontrado', $backup_name)
);
}
// Restaurar en wp_options
$restored = update_option(self::OLD_OPTION_NAME, $backup);
if ($restored) {
// Eliminar configuraciones de la tabla personalizada
$this->db_manager->delete_config(self::COMPONENT_NAME);
return array(
'success' => true,
'message' => 'Rollback completado exitosamente',
'backup_used' => $backup_name
);
} else {
return array(
'success' => false,
'message' => 'No se pudo restaurar el backup'
);
}
}
/**
* Buscar el backup más reciente
*
* @return string|null Nombre del backup más reciente o null
*/
private function find_latest_backup() {
global $wpdb;
// Buscar opciones que empiecen con el patrón de backup
$pattern = self::OLD_OPTION_NAME . '_backup_%';
$backup_name = $wpdb->get_var($wpdb->prepare(
"SELECT option_name FROM {$wpdb->options}
WHERE option_name LIKE %s
ORDER BY option_id DESC
LIMIT 1",
$pattern
));
return $backup_name;
}
/**
* Listar todos los backups disponibles
*
* @return array Lista de nombres de backups
*/
public function list_backups() {
global $wpdb;
$pattern = self::OLD_OPTION_NAME . '_backup_%';
$backups = $wpdb->get_col($wpdb->prepare(
"SELECT option_name FROM {$wpdb->options}
WHERE option_name LIKE %s
ORDER BY option_id DESC",
$pattern
));
return $backups;
}
/**
* Eliminar un backup específico
*
* @param string $backup_name Nombre del backup a eliminar
* @return bool True si se eliminó, false si no
*/
public function delete_backup($backup_name) {
return delete_option($backup_name);
}
/**
* Obtener estadísticas de la migración
*
* @return array Estadísticas
*/
public function get_migration_stats() {
$old_options = get_option(self::OLD_OPTION_NAME, array());
$new_config = $this->db_manager->get_config(self::COMPONENT_NAME);
$backups = $this->list_backups();
return array(
'is_migrated' => $this->is_migrated(),
'old_options_count' => count($old_options),
'new_config_count' => count($new_config),
'backups_count' => count($backups),
'backups' => $backups
);
}
}

View File

@@ -1,106 +0,0 @@
<?php
/**
* Validator Class
*
* Validación de datos por componentes
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class APUS_Validator {
/**
* Validar todas las configuraciones
*/
public function validate($data) {
$errors = array();
// Validar estructura base
if (!isset($data['components']) || !is_array($data['components'])) {
$errors[] = 'Estructura de datos inválida';
return array('valid' => false, 'errors' => $errors);
}
// Validar Top Bar
if (isset($data['components']['top_bar'])) {
$top_bar_errors = $this->validate_top_bar($data['components']['top_bar']);
$errors = array_merge($errors, $top_bar_errors);
}
return array(
'valid' => empty($errors),
'errors' => $errors
);
}
/**
* Validar Top Bar
*/
public function validate_top_bar($top_bar) {
$errors = array();
// Validar icon_class
if (!empty($top_bar['icon_class']) && strlen($top_bar['icon_class']) > 50) {
$errors[] = 'La clase del icono no puede exceder 50 caracteres';
}
// Validar highlight_text
if (!empty($top_bar['highlight_text']) && strlen($top_bar['highlight_text']) > 30) {
$errors[] = 'El texto destacado no puede exceder 30 caracteres';
}
// Validar message_text
if (empty($top_bar['message_text'])) {
$errors[] = 'El mensaje principal es obligatorio';
} elseif (strlen($top_bar['message_text']) > 250) {
$errors[] = 'El mensaje principal no puede exceder 250 caracteres';
}
// Validar link_text
if (!empty($top_bar['link_text']) && strlen($top_bar['link_text']) > 50) {
$errors[] = 'El texto del enlace no puede exceder 50 caracteres';
}
// Validar link_url (acepta URLs completas y relativas que empiecen con /)
if (!empty($top_bar['link_url'])) {
$url = $top_bar['link_url'];
$is_valid_url = filter_var($url, FILTER_VALIDATE_URL) !== false;
$is_relative_url = preg_match('/^\//', $url);
if (!$is_valid_url && !$is_relative_url) {
$errors[] = 'La URL del enlace no es válida';
}
}
// Validar link_target
if (!in_array($top_bar['link_target'] ?? '', array('_self', '_blank'))) {
$errors[] = 'El target del enlace debe ser _self o _blank';
}
// Validar colores
if (!empty($top_bar['custom_styles']['background_color']) && !preg_match('/^#[a-f0-9]{6}$/i', $top_bar['custom_styles']['background_color'])) {
$errors[] = 'El color de fondo debe ser un color hexadecimal válido';
}
if (!empty($top_bar['custom_styles']['text_color']) && !preg_match('/^#[a-f0-9]{6}$/i', $top_bar['custom_styles']['text_color'])) {
$errors[] = 'El color de texto debe ser un color hexadecimal válido';
}
if (!empty($top_bar['custom_styles']['highlight_color']) && !preg_match('/^#[a-f0-9]{6}$/i', $top_bar['custom_styles']['highlight_color'])) {
$errors[] = 'El color del highlight debe ser un color hexadecimal válido';
}
if (!empty($top_bar['custom_styles']['link_hover_color']) && !preg_match('/^#[a-f0-9]{6}$/i', $top_bar['custom_styles']['link_hover_color'])) {
$errors[] = 'El color hover del enlace debe ser un color hexadecimal válido';
}
// Validar font_size
if (!in_array($top_bar['custom_styles']['font_size'] ?? '', array('small', 'normal', 'large'))) {
$errors[] = 'El tamaño de fuente debe ser small, normal o large';
}
return $errors;
}
}

View File

@@ -1,173 +0,0 @@
<?php
/**
* Hero Section Sanitizer
*
* Sanitiza configuraciones del componente Hero Section
*
* @package Apus_Theme
* @subpackage Admin_Panel\Sanitizers
* @since 2.1.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class APUS_HeroSection_Sanitizer
*
* Sanitiza todas las configuraciones del componente Hero Section
*/
class APUS_HeroSection_Sanitizer {
/**
* Obtiene los valores por defecto del Hero Section
*
* @return array Valores por defecto
* @since 2.1.0
*/
public function get_defaults() {
return array(
// Activación y Visibilidad
'enabled' => true,
'show_on_mobile' => true,
'show_on_desktop' => true,
// Contenido y Estructura
'show_category_badges' => true,
'category_badge_icon' => 'bi bi-folder-fill',
'excluded_categories' => array('Uncategorized', 'Sin categoría'),
'title_alignment' => 'center',
'title_display_class' => 'display-5',
// Colores del Hero
'use_gradient_background' => true,
'gradient_start_color' => '#1e3a5f',
'gradient_end_color' => '#2c5282',
'gradient_angle' => 135,
'hero_text_color' => '#ffffff',
'solid_background_color' => '#1e3a5f',
// Colores de Category Badges
'badge_bg_color' => 'rgba(255, 255, 255, 0.15)',
'badge_bg_hover_color' => 'rgba(255, 133, 0, 0.2)',
'badge_border_color' => 'rgba(255, 255, 255, 0.2)',
'badge_text_color' => 'rgba(255, 255, 255, 0.95)',
'badge_icon_color' => '#FFB800',
// Espaciado y Dimensiones
'hero_padding_vertical' => 3.0,
'hero_padding_horizontal' => 0.0,
'hero_margin_bottom' => 1.5,
'badges_gap' => 0.5,
'badge_padding_vertical' => 0.375,
'badge_padding_horizontal' => 0.875,
'badge_border_radius' => 20,
// Tipografía
'h1_font_weight' => 700,
'badge_font_size' => 0.813,
'badge_font_weight' => 500,
'h1_line_height' => 1.4,
// Efectos Visuales
'enable_h1_text_shadow' => true,
'h1_text_shadow' => '1px 1px 2px rgba(0, 0, 0, 0.2)',
'enable_hero_box_shadow' => true,
'hero_box_shadow' => '0 4px 16px rgba(30, 58, 95, 0.25)',
'enable_badge_backdrop_filter' => true,
'badge_backdrop_filter' => 'blur(10px)',
// Transiciones y Animaciones
'badge_transition_speed' => 'normal',
'badge_hover_effect' => 'background',
// Avanzado
'custom_hero_classes' => '',
'custom_badge_classes' => ''
);
}
/**
* Sanitiza los datos del Hero Section
*
* @param array $data Datos sin sanitizar del Hero Section
* @return array Datos sanitizados
*/
public function sanitize($data) {
return array_merge(
// Activación y Visibilidad - Booleanos
APUS_Sanitizer_Helper::sanitize_booleans($data, array(
'enabled', 'show_on_mobile', 'show_on_desktop', 'show_category_badges',
'use_gradient_background', 'enable_h1_text_shadow', 'enable_hero_box_shadow',
'enable_badge_backdrop_filter'
)),
// Contenido y Estructura - Textos
APUS_Sanitizer_Helper::sanitize_texts($data, array(
'category_badge_icon' => 'bi bi-folder-fill',
'title_display_class' => 'display-5',
'h1_text_shadow' => '1px 1px 2px rgba(0, 0, 0, 0.2)',
'hero_box_shadow' => '0 4px 16px rgba(30, 58, 95, 0.25)',
'badge_backdrop_filter' => 'blur(10px)',
'custom_hero_classes' => '',
'custom_badge_classes' => ''
)),
// Colores de Category Badges - RGBA strings (text)
array(
'badge_bg_color' => APUS_Sanitizer_Helper::sanitize_text($data, 'badge_bg_color', 'rgba(255, 255, 255, 0.15)'),
'badge_bg_hover_color' => APUS_Sanitizer_Helper::sanitize_text($data, 'badge_bg_hover_color', 'rgba(255, 133, 0, 0.2)'),
'badge_border_color' => APUS_Sanitizer_Helper::sanitize_text($data, 'badge_border_color', 'rgba(255, 255, 255, 0.2)'),
'badge_text_color' => APUS_Sanitizer_Helper::sanitize_text($data, 'badge_text_color', 'rgba(255, 255, 255, 0.95)')
),
// Colores del Hero - Hex colors
array(
'gradient_start_color' => APUS_Sanitizer_Helper::sanitize_color($data, 'gradient_start_color', '#1e3a5f'),
'gradient_end_color' => APUS_Sanitizer_Helper::sanitize_color($data, 'gradient_end_color', '#2c5282'),
'hero_text_color' => APUS_Sanitizer_Helper::sanitize_color($data, 'hero_text_color', '#ffffff'),
'solid_background_color' => APUS_Sanitizer_Helper::sanitize_color($data, 'solid_background_color', '#1e3a5f'),
'badge_icon_color' => APUS_Sanitizer_Helper::sanitize_color($data, 'badge_icon_color', '#FFB800')
),
// Enums
APUS_Sanitizer_Helper::sanitize_enums($data, array(
'title_alignment' => array('allowed' => array('left', 'center', 'right'), 'default' => 'center'),
'badge_transition_speed' => array('allowed' => array('fast', 'normal', 'slow'), 'default' => 'normal'),
'badge_hover_effect' => array('allowed' => array('none', 'background', 'scale', 'brightness'), 'default' => 'background')
)),
// Enteros
APUS_Sanitizer_Helper::sanitize_ints($data, array(
'gradient_angle' => 135,
'badge_border_radius' => 20
)),
// Enteros en arrays (h1_font_weight, badge_font_weight)
array(
'h1_font_weight' => APUS_Sanitizer_Helper::sanitize_enum($data, 'h1_font_weight', array(400, 500, 600, 700), 700),
'badge_font_weight' => APUS_Sanitizer_Helper::sanitize_enum($data, 'badge_font_weight', array(400, 500, 600, 700), 500)
),
// Floats
APUS_Sanitizer_Helper::sanitize_floats($data, array(
'hero_padding_vertical' => 3.0,
'hero_padding_horizontal' => 0.0,
'hero_margin_bottom' => 1.5,
'badges_gap' => 0.5,
'badge_padding_vertical' => 0.375,
'badge_padding_horizontal' => 0.875,
'badge_font_size' => 0.813,
'h1_line_height' => 1.4
)),
// Array de strings
array('excluded_categories' => APUS_Sanitizer_Helper::sanitize_array_of_strings(
$data,
'excluded_categories',
array('Uncategorized', 'Sin categoría')
))
);
}
}

View File

@@ -1,99 +0,0 @@
<?php
/**
* Let's Talk Button Sanitizer
*
* Sanitiza configuraciones del componente Let's Talk Button
*
* @package Apus_Theme
* @subpackage Admin_Panel\Sanitizers
* @since 2.1.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class APUS_LetsTalkButton_Sanitizer
*
* Sanitiza todas las configuraciones del componente Let's Talk Button
*/
class APUS_LetsTalkButton_Sanitizer {
/**
* Obtiene los valores por defecto del Let's Talk Button
*
* @return array Valores por defecto
* @since 2.1.0
*/
public function get_defaults() {
return array(
'enabled' => true,
'text' => "Let's Talk",
'icon_class' => 'bi bi-lightning-charge-fill',
'show_icon' => true,
'position' => 'right',
'enable_box_shadow' => false,
'hover_effect' => 'none',
'modal_target' => '#contactModal',
'custom_styles' => array(
'background_color' => '#FF8600',
'background_hover_color' => '#FF6B35',
'text_color' => '#ffffff',
'icon_color' => '#ffffff',
'font_weight' => '600',
'padding_vertical' => 0.5,
'padding_horizontal' => 1.5,
'border_radius' => 6,
'border_width' => 0,
'border_color' => '',
'border_style' => 'solid',
'transition_speed' => 'normal',
'box_shadow' => '0 2px 8px rgba(0, 0, 0, 0.15)'
)
);
}
/**
* Sanitiza los datos del Let's Talk Button
*
* @param array $data Datos sin sanitizar del Let's Talk Button
* @return array Datos sanitizados
*/
public function sanitize($data) {
return array_merge(
// Booleanos
APUS_Sanitizer_Helper::sanitize_booleans($data, array(
'enabled', 'show_icon', 'enable_box_shadow'
)),
// Textos
APUS_Sanitizer_Helper::sanitize_texts($data, array(
'text', 'icon_class', 'modal_target'
)),
// Enums
APUS_Sanitizer_Helper::sanitize_enums($data, array(
'position' => array('allowed' => array('left', 'center', 'right'), 'default' => 'right'),
'hover_effect' => array('allowed' => array('none', 'scale', 'brightness'), 'default' => 'none')
)),
// Custom styles anidado
array('custom_styles' => APUS_Sanitizer_Helper::sanitize_nested_group($data, 'custom_styles', array(
'background_color' => array('type' => 'color', 'default' => ''),
'background_hover_color' => array('type' => 'color', 'default' => ''),
'text_color' => array('type' => 'color', 'default' => ''),
'icon_color' => array('type' => 'color', 'default' => ''),
'font_weight' => array('type' => 'text', 'default' => ''),
'padding_vertical' => array('type' => 'float', 'default' => 0.0),
'padding_horizontal' => array('type' => 'float', 'default' => 0.0),
'border_radius' => array('type' => 'int', 'default' => 0),
'border_width' => array('type' => 'int', 'default' => 0),
'border_color' => array('type' => 'color', 'default' => ''),
'border_style' => array('type' => 'enum', 'allowed' => array('solid', 'dashed', 'dotted'), 'default' => 'solid'),
'transition_speed' => array('type' => 'enum', 'allowed' => array('fast', 'normal', 'slow'), 'default' => 'normal'),
'box_shadow' => array('type' => 'text', 'default' => '')
)))
);
}
}

View File

@@ -1,136 +0,0 @@
<?php
/**
* Navbar Sanitizer
*
* Sanitiza configuraciones del componente Navbar
*
* @package Apus_Theme
* @subpackage Admin_Panel\Sanitizers
* @since 2.1.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class APUS_Navbar_Sanitizer
*
* Sanitiza todas las configuraciones del componente Navbar
*/
class APUS_Navbar_Sanitizer {
/**
* Obtiene los valores por defecto del Navbar
*
* @return array Valores por defecto
* @since 2.1.0
*/
public function get_defaults() {
return array(
'enabled' => true,
'show_on_mobile' => true,
'show_on_desktop' => true,
'position' => 'sticky',
'responsive_breakpoint' => 'lg',
'enable_box_shadow' => true,
'enable_underline_effect' => true,
'enable_hover_background' => true,
'lets_talk_button' => array(
'enabled' => true,
'text' => "Let's Talk",
'icon_class' => 'bi bi-lightning-charge-fill',
'show_icon' => true,
'position' => 'right'
),
'dropdown' => array(
'enable_hover_desktop' => true,
'max_height' => 70,
'border_radius' => 8,
'item_padding_vertical' => 0.5,
'item_padding_horizontal' => 1.25
),
'custom_styles' => array(
'background_color' => '#1e3a5f',
'text_color' => '#ffffff',
'link_hover_color' => '#FF8600',
'link_hover_bg_color' => '#FF8600',
'dropdown_bg_color' => '#ffffff',
'dropdown_item_color' => '#4A5568',
'dropdown_item_hover_color' => '#FF8600',
'font_size' => 'normal',
'font_weight' => '500',
'box_shadow_intensity' => 'normal',
'border_radius' => 4,
'padding_vertical' => 0.75,
'link_padding_vertical' => 0.5,
'link_padding_horizontal' => 0.65,
'z_index' => 1030,
'transition_speed' => 'normal'
)
);
}
/**
* Sanitiza los datos del Navbar
*
* @param array $data Datos sin sanitizar del Navbar
* @return array Datos sanitizados
*/
public function sanitize($data) {
return array_merge(
// Booleanos principales
APUS_Sanitizer_Helper::sanitize_booleans($data, array(
'enabled', 'show_on_mobile', 'show_on_desktop',
'enable_box_shadow', 'enable_underline_effect', 'enable_hover_background'
)),
// Enums principales
APUS_Sanitizer_Helper::sanitize_enums($data, array(
'position' => array('allowed' => array('sticky', 'static', 'fixed'), 'default' => 'sticky'),
'responsive_breakpoint' => array('allowed' => array('sm', 'md', 'lg', 'xl', 'xxl'), 'default' => 'lg')
)),
// Let's Talk Button anidado
array('lets_talk_button' => APUS_Sanitizer_Helper::sanitize_nested_group($data, 'lets_talk_button', array(
'enabled' => array('type' => 'bool'),
'text' => array('type' => 'text', 'default' => ''),
'icon_class' => array('type' => 'text', 'default' => ''),
'show_icon' => array('type' => 'bool'),
'position' => array('type' => 'enum', 'allowed' => array('left', 'center', 'right'), 'default' => 'right')
))),
// Dropdown anidado
array('dropdown' => APUS_Sanitizer_Helper::sanitize_nested_group($data, 'dropdown', array(
'enable_hover_desktop' => array('type' => 'bool'),
'max_height' => array('type' => 'int', 'default' => 70),
'border_radius' => array('type' => 'int', 'default' => 8),
'item_padding_vertical' => array('type' => 'float', 'default' => 0.5),
'item_padding_horizontal' => array('type' => 'float', 'default' => 1.25)
))),
// Custom styles anidado
array('custom_styles' => APUS_Sanitizer_Helper::sanitize_nested_group($data, 'custom_styles', array(
'background_color' => array('type' => 'color', 'default' => ''),
'text_color' => array('type' => 'color', 'default' => ''),
'link_hover_color' => array('type' => 'color', 'default' => ''),
'link_hover_bg_color' => array('type' => 'color', 'default' => ''),
'dropdown_bg_color' => array('type' => 'color', 'default' => ''),
'dropdown_item_color' => array('type' => 'color', 'default' => ''),
'dropdown_item_hover_color' => array('type' => 'color', 'default' => ''),
'font_size' => array('type' => 'enum', 'allowed' => array('small', 'normal', 'large'), 'default' => 'normal'),
'font_weight' => array('type' => 'enum', 'allowed' => array('400', '500', '600', '700'), 'default' => '500'),
'box_shadow_intensity' => array('type' => 'enum', 'allowed' => array('none', 'light', 'normal', 'strong'), 'default' => 'normal'),
'border_radius' => array('type' => 'int', 'default' => 4),
'padding_vertical' => array('type' => 'float', 'default' => 0.75),
'link_padding_vertical' => array('type' => 'float', 'default' => 0.5),
'link_padding_horizontal' => array('type' => 'float', 'default' => 0.65),
'z_index' => array('type' => 'int', 'default' => 1030),
'transition_speed' => array('type' => 'enum', 'allowed' => array('fast', 'normal', 'slow'), 'default' => 'normal')
)))
);
}
}

View File

@@ -1,271 +0,0 @@
<?php
/**
* Sanitizer Helper
*
* Métodos estáticos reutilizables para sanitización de datos
*
* @package Apus_Theme
* @subpackage Admin_Panel\Sanitizers
* @since 2.1.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class APUS_Sanitizer_Helper
*
* Proporciona métodos estáticos para sanitización común,
* eliminando código duplicado en los sanitizadores de componentes
*/
class APUS_Sanitizer_Helper {
/**
* Sanitiza un valor booleano
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @return bool Valor booleano sanitizado
*/
public static function sanitize_boolean($data, $key) {
return !empty($data[$key]);
}
/**
* Sanitiza múltiples valores booleanos
*
* @param array $data Array de datos
* @param array $keys Array de claves a sanitizar
* @return array Array asociativo con valores booleanos sanitizados
*/
public static function sanitize_booleans($data, $keys) {
$result = array();
foreach ($keys as $key) {
$result[$key] = self::sanitize_boolean($data, $key);
}
return $result;
}
/**
* Sanitiza un campo de texto con valor por defecto
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param string $default Valor por defecto (default: '')
* @return string Texto sanitizado
*/
public static function sanitize_text($data, $key, $default = '') {
return sanitize_text_field($data[$key] ?? $default);
}
/**
* Sanitiza múltiples campos de texto
*
* @param array $data Array de datos
* @param array $keys Array de claves a sanitizar
* @param string $default Valor por defecto para todos (default: '')
* @return array Array asociativo con textos sanitizados
*/
public static function sanitize_texts($data, $keys, $default = '') {
$result = array();
foreach ($keys as $key) {
$result[$key] = self::sanitize_text($data, $key, $default);
}
return $result;
}
/**
* Sanitiza un color hexadecimal con valor por defecto
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param string $default Valor por defecto (default: '')
* @return string Color hexadecimal sanitizado
*/
public static function sanitize_color($data, $key, $default = '') {
return sanitize_hex_color($data[$key] ?? $default);
}
/**
* Sanitiza múltiples colores hexadecimales
*
* @param array $data Array de datos
* @param array $keys Array de claves a sanitizar
* @param string $default Valor por defecto para todos (default: '')
* @return array Array asociativo con colores sanitizados
*/
public static function sanitize_colors($data, $keys, $default = '') {
$result = array();
foreach ($keys as $key) {
$result[$key] = self::sanitize_color($data, $key, $default);
}
return $result;
}
/**
* Sanitiza un valor con validación enum (in_array)
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param array $allowed_values Valores permitidos
* @param mixed $default Valor por defecto
* @return mixed Valor sanitizado
*/
public static function sanitize_enum($data, $key, $allowed_values, $default) {
return in_array($data[$key] ?? '', $allowed_values, true)
? $data[$key]
: $default;
}
/**
* Sanitiza múltiples valores enum
*
* @param array $data Array de datos
* @param array $config Array de configuración [key => ['allowed' => [...], 'default' => ...]]
* @return array Array asociativo con valores enum sanitizados
*/
public static function sanitize_enums($data, $config) {
$result = array();
foreach ($config as $key => $settings) {
$result[$key] = self::sanitize_enum(
$data,
$key,
$settings['allowed'],
$settings['default']
);
}
return $result;
}
/**
* Sanitiza un valor entero con valor por defecto
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param int $default Valor por defecto
* @return int Entero sanitizado
*/
public static function sanitize_int($data, $key, $default = 0) {
return isset($data[$key]) ? intval($data[$key]) : $default;
}
/**
* Sanitiza múltiples valores enteros
*
* @param array $data Array de datos
* @param array $config Array de configuración [key => default_value]
* @return array Array asociativo con enteros sanitizados
*/
public static function sanitize_ints($data, $config) {
$result = array();
foreach ($config as $key => $default) {
$result[$key] = self::sanitize_int($data, $key, $default);
}
return $result;
}
/**
* Sanitiza un valor float con valor por defecto
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param float $default Valor por defecto
* @return float Float sanitizado
*/
public static function sanitize_float($data, $key, $default = 0.0) {
return isset($data[$key]) ? floatval($data[$key]) : $default;
}
/**
* Sanitiza múltiples valores float
*
* @param array $data Array de datos
* @param array $config Array de configuración [key => default_value]
* @return array Array asociativo con floats sanitizados
*/
public static function sanitize_floats($data, $config) {
$result = array();
foreach ($config as $key => $default) {
$result[$key] = self::sanitize_float($data, $key, $default);
}
return $result;
}
/**
* Sanitiza una URL con valor por defecto
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param string $default Valor por defecto (default: '')
* @return string URL sanitizada
*/
public static function sanitize_url($data, $key, $default = '') {
return esc_url_raw($data[$key] ?? $default);
}
/**
* Sanitiza un array de strings
*
* @param array $data Array de datos
* @param string $key Clave del dato
* @param array $default Array por defecto
* @return array Array de strings sanitizados
*/
public static function sanitize_array_of_strings($data, $key, $default = array()) {
return isset($data[$key]) && is_array($data[$key])
? array_map('sanitize_text_field', $data[$key])
: $default;
}
/**
* Sanitiza un grupo de campos anidados (custom_styles, dropdown, etc.)
*
* @param array $data Array de datos completo
* @param string $group_key Clave del grupo (ej: 'custom_styles')
* @param array $sanitization_rules Reglas de sanitización por campo
* Formato: [
* 'campo' => ['type' => 'text|color|int|float|enum|bool', 'default' => valor, 'allowed' => array()]
* ]
* @return array Array con campos del grupo sanitizados
*/
public static function sanitize_nested_group($data, $group_key, $sanitization_rules) {
$result = array();
$group_data = $data[$group_key] ?? array();
foreach ($sanitization_rules as $field => $rule) {
$type = $rule['type'];
$default = $rule['default'] ?? null;
switch ($type) {
case 'text':
$result[$field] = self::sanitize_text($group_data, $field, $default ?? '');
break;
case 'color':
$result[$field] = self::sanitize_color($group_data, $field, $default ?? '');
break;
case 'int':
$result[$field] = self::sanitize_int($group_data, $field, $default ?? 0);
break;
case 'float':
$result[$field] = self::sanitize_float($group_data, $field, $default ?? 0.0);
break;
case 'enum':
$result[$field] = self::sanitize_enum(
$group_data,
$field,
$rule['allowed'] ?? array(),
$default
);
break;
case 'bool':
$result[$field] = self::sanitize_boolean($group_data, $field);
break;
default:
$result[$field] = $group_data[$field] ?? $default;
}
}
return $result;
}
}

View File

@@ -1,88 +0,0 @@
<?php
/**
* Top Bar Sanitizer
*
* Sanitiza configuraciones del componente Top Bar
*
* @package Apus_Theme
* @subpackage Admin_Panel\Sanitizers
* @since 2.1.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class APUS_TopBar_Sanitizer
*
* Sanitiza todas las configuraciones del componente Top Bar
*/
class APUS_TopBar_Sanitizer {
/**
* Obtiene los valores por defecto del Top Bar
*
* @return array Valores por defecto
* @since 2.1.0
*/
public function get_defaults() {
return array(
'enabled' => true,
'show_on_mobile' => true,
'show_on_desktop' => true,
'icon_class' => 'bi bi-megaphone-fill',
'show_icon' => true,
'highlight_text' => 'Nuevo:',
'message_text' => 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.',
'link_text' => 'Ver Catálogo',
'link_url' => '/catalogo',
'link_target' => '_self',
'show_link' => true,
'custom_styles' => array(
'background_color' => '#0E2337',
'text_color' => '#ffffff',
'highlight_color' => '#FF8600',
'link_hover_color' => '#FF8600',
'font_size' => 'normal'
)
);
}
/**
* Sanitiza los datos del Top Bar
*
* @param array $data Datos sin sanitizar del Top Bar
* @return array Datos sanitizados
*/
public function sanitize($data) {
return array_merge(
// Booleanos
APUS_Sanitizer_Helper::sanitize_booleans($data, array(
'enabled', 'show_on_mobile', 'show_on_desktop', 'show_icon', 'show_link'
)),
// Textos
APUS_Sanitizer_Helper::sanitize_texts($data, array(
'icon_class', 'highlight_text', 'message_text', 'link_text'
)),
// URL
array('link_url' => APUS_Sanitizer_Helper::sanitize_url($data, 'link_url')),
// Enum
array('link_target' => APUS_Sanitizer_Helper::sanitize_enum(
$data, 'link_target', array('_self', '_blank'), '_self'
)),
// Custom styles anidado
array('custom_styles' => APUS_Sanitizer_Helper::sanitize_nested_group($data, 'custom_styles', array(
'background_color' => array('type' => 'color', 'default' => ''),
'text_color' => array('type' => 'color', 'default' => ''),
'highlight_color' => array('type' => 'color', 'default' => ''),
'link_hover_color' => array('type' => 'color', 'default' => ''),
'font_size' => array('type' => 'enum', 'allowed' => array('small', 'normal', 'large'), 'default' => 'normal')
)))
);
}
}

View File

@@ -1,68 +0,0 @@
<?php
/**
* Admin Panel Module - Initialization
*
* Sistema de configuración por componentes
* Cada componente del tema es configurable desde el admin panel
*
* @package Apus_Theme
* @since 2.0.0
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Module constants
define('APUS_ADMIN_PANEL_VERSION', '2.1.4');
define('APUS_ADMIN_PANEL_PATH', get_template_directory() . '/admin/');
define('APUS_ADMIN_PANEL_URL', get_template_directory_uri() . '/admin/');
// Load classes
require_once APUS_ADMIN_PANEL_PATH . 'includes/class-admin-menu.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/class-db-manager.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/class-data-migrator.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/class-validator.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/class-theme-options-migrator.php';
// Load sanitizer helper (DRY - @since 2.1.0)
require_once APUS_ADMIN_PANEL_PATH . 'includes/sanitizers/class-sanitizer-helper.php';
// Load sanitizers (Strategy Pattern - @since 2.1.0)
require_once APUS_ADMIN_PANEL_PATH . 'includes/sanitizers/class-topbar-sanitizer.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/sanitizers/class-navbar-sanitizer.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/sanitizers/class-letstalkbutton-sanitizer.php';
require_once APUS_ADMIN_PANEL_PATH . 'includes/sanitizers/class-herosection-sanitizer.php';
// Settings Manager (debe cargarse DESPUÉS de sanitizers)
require_once APUS_ADMIN_PANEL_PATH . 'includes/class-settings-manager.php';
// Initialize Database Manager
new APUS_DB_Manager();
// Execute data migration (one-time operation)
add_action('admin_init', function() {
$migrator = new APUS_Data_Migrator();
$result = $migrator->maybe_migrate();
if ($result['success'] && isset($result['total_migrated'])) {
error_log('APUS Theme: Migración completada - ' . $result['total_migrated'] . ' registros migrados');
}
});
// Execute Theme Options migration (one-time operation)
add_action('admin_init', function() {
$theme_options_migrator = new APUS_Theme_Options_Migrator();
// Solo ejecutar si no se ha migrado ya
if (!$theme_options_migrator->is_migrated()) {
$result = $theme_options_migrator->migrate();
if ($result['success']) {
error_log('APUS Theme: Theme Options migradas exitosamente - ' . $result['migrated'] . ' configuraciones');
} else {
error_log('APUS Theme: Error en migración de Theme Options - ' . $result['message']);
}
}
});

View File

@@ -1,530 +0,0 @@
<?php
/**
* Admin Panel - Main Page
*
* Interfaz de administración de componentes del tema
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
?>
<div class="wrap apus-admin-panel">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<p class="description">Configure los componentes del tema Apus</p>
<!-- Navigation Tabs -->
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" data-bs-target="#topBarTab" href="#topBarTab" role="tab">
<i class="bi bi-megaphone-fill me-2"></i>
Top Bar
</a>
</li>
<!-- Más tabs aquí: Navbar, Hero, Footer, etc. -->
</ul>
<!-- Tab Content -->
<div class="tab-content mt-3">
<!-- ============================= -->
<!-- TAB: TOP BAR - VERSIÓN MEJORADA -->
<!-- ============================= -->
<div id="topBarTab" class="tab-pane fade show active" role="tabpanel">
<!-- Header del Tab -->
<div class="tab-header mb-4">
<div class="d-flex align-items-center justify-content-between">
<div>
<h3 class="mb-1 text-navy-primary">
<i class="bi bi-megaphone-fill me-2 text-orange-primary"></i>
Configuración Top Bar
</h3>
<p class="text-neutral-600 mb-0">
Personaliza la barra de anuncios superior de tu sitio
</p>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="resetTopBarDefaults">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar valores por defecto
</button>
</div>
</div>
<!-- Row para 2 cards por fila -->
<div class="row">
<!-- ============================= -->
<!-- GRUPO 1: ACTIVACIÓN -->
<!-- ============================= -->
<div class="col-md-6">
<div class="form-section card shadow-sm border-0 mb-4 h-100">
<div class="card-body">
<h4 class="section-title">
<span class="title-icon">
<i class="bi bi-toggle-on"></i>
</span>
Activación y Visibilidad
</h4>
<!-- Enabled -->
<div class="mb-4">
<label class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-power text-orange-primary me-1"></i>
Estado del Componente
</label>
<div class="toggle-container">
<div class="form-check form-switch form-switch-lg">
<input class="form-check-input"
type="checkbox"
id="topBarEnabled"
role="switch"
checked>
<label class="form-check-label" for="topBarEnabled">
Activar Top Bar
</label>
</div>
<small class="form-text text-neutral-700 d-block mt-2">
Activa o desactiva el Top Bar en todo el sitio
</small>
</div>
</div>
<!-- Show on Mobile -->
<div class="mb-4">
<label class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-phone text-orange-primary me-1"></i>
Visibilidad Mobile
</label>
<div class="toggle-container">
<div class="form-check form-switch form-switch-lg">
<input class="form-check-input"
type="checkbox"
id="topBarShowOnMobile"
role="switch"
checked>
<label class="form-check-label" for="topBarShowOnMobile">
Mostrar en dispositivos móviles
</label>
</div>
<small class="form-text text-neutral-700 d-block mt-2">
Pantallas menores a 768px
</small>
</div>
</div>
<!-- Show on Desktop -->
<div class="mb-0">
<label class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-display text-orange-primary me-1"></i>
Visibilidad Desktop
</label>
<div class="toggle-container">
<div class="form-check form-switch form-switch-lg">
<input class="form-check-input"
type="checkbox"
id="topBarShowOnDesktop"
role="switch"
checked>
<label class="form-check-label" for="topBarShowOnDesktop">
Mostrar en desktop
</label>
</div>
<small class="form-text text-neutral-700 d-block mt-2">
Pantallas de 768px en adelante
</small>
</div>
</div>
</div>
</div>
<div class="row">
<!-- ============================= -->
<!-- GRUPO 2: CONTENIDO -->
<!-- ============================= -->
<div class="col-md-6">
<div class="form-section card shadow-sm border-0 mb-4 h-100">
<div class="card-body">
<h4 class="section-title">
<span class="title-icon">
<i class="bi bi-card-text"></i>
</span>
Contenido y Mensajes
</h4>
<!-- Icono -->
<div class="row g-4 mb-4">
<div class="col-md-8">
<div class="form-group">
<label for="topBarIconClass" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-emoji-smile text-orange-primary me-1"></i>
Clase del icono
<span class="badge bg-neutral-100 text-neutral-600 ms-2">Bootstrap Icons</span>
</label>
<div class="input-group input-group-merge">
<span class="input-group-text bg-neutral-50 border-end-0">
<i class="bi bi-code-slash text-neutral-600"></i>
</span>
<input type="text"
id="topBarIconClass"
class="form-control border-start-0 ps-0"
placeholder="Ej: bi bi-megaphone-fill"
maxlength="50"
value="bi bi-megaphone-fill">
</div>
<small class="form-text text-neutral-700 d-flex align-items-center mt-2">
<i class="bi bi-info-circle me-1"></i>
Ver iconos disponibles:
<a href="https://icons.getbootstrap.com/" target="_blank" class="ms-1 text-orange-primary">
Bootstrap Icons <i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
</small>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label class="form-label text-neutral-700 fw-medium mb-3">
Opciones de Icono
</label>
<div class="toggle-container">
<div class="form-check form-switch form-switch-lg">
<input class="form-check-input"
type="checkbox"
id="topBarShowIcon"
role="switch"
checked>
<label class="form-check-label" for="topBarShowIcon">
Mostrar icono
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Texto destacado -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<div class="form-group">
<label for="topBarHighlightText" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-bookmark-star text-orange-primary me-1"></i>
Texto destacado
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Opcional</span>
</label>
<input type="text"
id="topBarHighlightText"
class="form-control form-control-lg"
placeholder='Ej: "Nuevo:" o "Promoción:"'
maxlength="30"
value="Nuevo:">
<small class="form-text text-neutral-700 d-flex align-items-center mt-2">
<i class="bi bi-lightbulb text-warning me-1"></i>
Se muestra en <strong class="mx-1">negritas</strong> y con <span class="text-orange-primary fw-bold mx-1">color destacado</span>. Dejar vacío para omitir.
</small>
</div>
</div>
</div>
<!-- Mensaje principal -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<div class="form-group">
<label for="topBarMessageText" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-chat-left-text text-orange-primary me-1"></i>
Mensaje principal
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Requerido</span>
</label>
<textarea id="topBarMessageText"
class="form-control form-control-lg"
rows="3"
maxlength="250"
placeholder="Ej: Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025."
required>Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</textarea>
<div class="d-flex justify-content-between align-items-center mt-2">
<small class="form-text text-neutral-700">
<i class="bi bi-info-circle me-1"></i>
Mensaje que se mostrará en la barra superior
</small>
<small class="form-text">
<span id="topBarMessageTextCount" class="fw-bold">75</span><span class="text-neutral-600">/250 caracteres</span>
</small>
</div>
<div class="progress mt-2" style="height: 4px;">
<div id="topBarMessageTextProgress"
class="progress-bar bg-orange-primary"
role="progressbar"
style="width: 30%"
aria-valuenow="75"
aria-valuemin="0"
aria-valuemax="250"></div>
</div>
</div>
</div>
</div>
<!-- Enlace -->
<div class="row g-4">
<div class="col-md-5">
<div class="form-group">
<label for="topBarLinkText" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-link-45deg text-orange-primary me-1"></i>
Texto del enlace
</label>
<input type="text"
id="topBarLinkText"
class="form-control"
placeholder="Ej: Ver Catálogo"
maxlength="50"
value="Ver Catálogo →">
</div>
</div>
<div class="col-md-5">
<div class="form-group">
<label for="topBarLinkUrl" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-globe text-orange-primary me-1"></i>
URL del enlace
</label>
<input type="url"
id="topBarLinkUrl"
class="form-control"
placeholder="Ej: /catalogo o https://ejemplo.com"
value="/catalogo">
<small class="form-text text-neutral-700 mt-2 d-block">
URLs relativas (/page) o absolutas (https://...)
</small>
</div>
</div>
<div class="col-md-2">
<div class="form-group">
<label for="topBarLinkTarget" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-window text-orange-primary me-1"></i>
Target
</label>
<select id="topBarLinkTarget" class="form-select">
<option value="_self" selected>Misma ventana</option>
<option value="_blank">Nueva ventana</option>
</select>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<div class="toggle-container">
<div class="form-check form-switch form-switch-lg">
<input class="form-check-input"
type="checkbox"
id="topBarShowLink"
role="switch"
checked>
<label class="form-check-label" for="topBarShowLink">
<strong>Mostrar enlace</strong>
<span class="text-neutral-700 ms-2">- Activa para incluir un botón de acción</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Row para segunda fila de cards -->
<div class="row">
<!-- ============================= -->
<!-- GRUPO 3: ESTILOS AVANZADOS -->
<!-- ============================= -->
<div class="col-md-6">
<div class="form-section card shadow-sm border-0 mb-4 h-100">
<div class="card-body">
<h4 class="section-title">
<span class="title-icon">
<i class="bi bi-palette"></i>
</span>
Estilos Personalizados
</h4>
<!-- Colores (4 en una fila) -->
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="form-group">
<label for="topBarBgColor" class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-paint-bucket text-orange-primary me-1"></i>
Color de fondo
</label>
<div class="color-picker-wrapper">
<input type="color"
id="topBarBgColor"
class="form-control form-control-color"
value="#0E2337"
title="Seleccionar color de fondo">
<div class="color-preview-text mt-2">
<code class="text-neutral-600 small">#0E2337</code>
<small class="text-neutral-700 d-block">Navy Dark (Default)</small>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="topBarTextColor" class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-fonts text-orange-primary me-1"></i>
Color de texto
</label>
<div class="color-picker-wrapper">
<input type="color"
id="topBarTextColor"
class="form-control form-control-color"
value="#ffffff"
title="Seleccionar color de texto">
<div class="color-preview-text mt-2">
<code class="text-neutral-600 small">#ffffff</code>
<small class="text-neutral-700 d-block">White (Default)</small>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="topBarHighlightColor" class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-star text-orange-primary me-1"></i>
Color destacado
</label>
<div class="color-picker-wrapper">
<input type="color"
id="topBarHighlightColor"
class="form-control form-control-color"
value="#FF8600"
title="Seleccionar color destacado">
<div class="color-preview-text mt-2">
<code class="text-neutral-600 small">#FF8600</code>
<small class="text-neutral-700 d-block">Orange Primary (Default)</small>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="topBarLinkHoverColor" class="form-label text-neutral-700 fw-medium mb-3">
<i class="bi bi-cursor text-orange-primary me-1"></i>
Hover enlace
</label>
<div class="color-picker-wrapper">
<input type="color"
id="topBarLinkHoverColor"
class="form-control form-control-color"
value="#FF6B35"
title="Seleccionar color hover del enlace">
<div class="color-preview-text mt-2">
<code class="text-neutral-600 small">#FF6B35</code>
<small class="text-neutral-700 d-block">Orange Hover (Default)</small>
</div>
</div>
</div>
</div>
</div>
<!-- Tamaño de fuente -->
<div class="row g-4">
<div class="col-md-4">
<div class="form-group">
<label for="topBarFontSize" class="form-label text-neutral-700 fw-medium">
<i class="bi bi-type text-orange-primary me-1"></i>
Tamaño de fuente
</label>
<select id="topBarFontSize" class="form-select form-select-lg">
<option value="small">
Pequeño - 0.8rem (ideal para mucho texto)
</option>
<option value="normal" selected>
Normal - 0.9rem (recomendado)
</option>
<option value="large">
Grande - 1rem (máxima legibilidad)
</option>
</select>
<small class="form-text text-neutral-700 d-block mt-2">
<i class="bi bi-info-circle me-1"></i>
El tamaño afecta la altura total de la barra
</small>
</div>
</div>
<div class="col-md-8">
<div class="alert alert-info-custom d-flex align-items-start" role="alert">
<i class="bi bi-lightbulb-fill text-orange-primary me-3 fs-4"></i>
<div>
<h6 class="alert-heading mb-1">Tip de Diseño</h6>
<p class="mb-0 small">
Para mejor legibilidad, usa <strong>fondos oscuros</strong> (Navy Dark) con <strong>texto claro</strong> (White)
y <strong>acentos naranjas</strong> para las acciones importantes.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================= -->
<!-- VISTA PREVIA INTERACTIVA -->
<!-- ============================= -->
<div class="col-md-6">
<div class="form-section card shadow-sm border-0 mb-4 bg-neutral-50 h-100">
<div class="card-body">
<h4 class="section-title">
<span class="title-icon">
<i class="bi bi-eye"></i>
</span>
Vista Previa en Tiempo Real
</h4>
<div class="preview-container border border-2 border-neutral-100 rounded-3 p-4 bg-white">
<!-- Top Bar Preview -->
<div id="topBarPreview" class="top-bar-preview" style="background-color: #0E2337; color: #ffffff; padding: 12px 20px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 15px; flex-wrap: wrap;">
<i class="bi bi-megaphone-fill" style="font-size: 1.2rem; color: #FF8600;"></i>
<span style="font-weight: 700; color: #FF8600;">Nuevo:</span>
<span style="flex: 1; min-width: 300px; text-align: center;">Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</span>
<a href="#" style="color: #ffffff; text-decoration: underline; white-space: nowrap; transition: color 0.3s;">Ver Catálogo →</a>
</div>
<div class="text-center mt-4">
<small class="text-neutral-700">
<i class="bi bi-info-circle me-1"></i>
La vista previa se actualiza automáticamente al modificar los campos
</small>
</div>
</div>
<div class="mt-4 d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary">
<i class="bi bi-phone me-1"></i>
Ver en Mobile
</button>
<button type="button" class="btn btn-outline-secondary">
<i class="bi bi-display me-1"></i>
Ver en Desktop
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="admin-actions mt-4">
<button type="button" id="saveSettings" class="button button-primary" disabled>
<i class="bi bi-save me-2"></i>Guardar Cambios
</button>
<span class="spinner" style="display: none; float: none; margin-left: 10px;"></span>
</div>
</div>

View File

@@ -1,281 +0,0 @@
<?php
/**
* Admin Panel - Theme Options Migration Page
*
* Interfaz para migrar Theme Options de wp_options a tabla personalizada
*
* @package Apus_Theme
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
// Instanciar migrator
$migrator = new APUS_Theme_Options_Migrator();
// Obtener estadísticas
$stats = $migrator->get_migration_stats();
// Procesar acciones
$message = '';
$message_type = '';
if (isset($_POST['apus_migrate_action'])) {
check_admin_referer('apus_migration_action', 'apus_migration_nonce');
$action = sanitize_text_field($_POST['apus_migrate_action']);
switch ($action) {
case 'migrate':
$result = $migrator->migrate();
$message = $result['message'];
$message_type = $result['success'] ? 'success' : 'error';
// Actualizar estadísticas
$stats = $migrator->get_migration_stats();
break;
case 'rollback':
$backup_name = isset($_POST['backup_name']) ? sanitize_text_field($_POST['backup_name']) : null;
$result = $migrator->rollback($backup_name);
$message = $result['message'];
$message_type = $result['success'] ? 'success' : 'error';
// Actualizar estadísticas
$stats = $migrator->get_migration_stats();
break;
case 'delete_backup':
$backup_name = isset($_POST['backup_name']) ? sanitize_text_field($_POST['backup_name']) : '';
if ($backup_name && $migrator->delete_backup($backup_name)) {
$message = 'Backup eliminado correctamente';
$message_type = 'success';
} else {
$message = 'Error al eliminar backup';
$message_type = 'error';
}
// Actualizar estadísticas
$stats = $migrator->get_migration_stats();
break;
}
}
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<p class="description">Migración de Theme Options desde wp_options a tabla personalizada wp_apus_theme_components</p>
<?php if ($message): ?>
<div class="notice notice-<?php echo esc_attr($message_type); ?> is-dismissible">
<p><?php echo esc_html($message); ?></p>
</div>
<?php endif; ?>
<!-- Migration Status Card -->
<div class="card mt-4" style="max-width: 800px;">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="bi bi-info-circle me-2"></i>
Estado de la Migración
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<strong>Estado:</strong>
<?php if ($stats['is_migrated']): ?>
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Migrado
</span>
<?php else: ?>
<span class="badge bg-warning text-dark">
<i class="bi bi-exclamation-triangle me-1"></i>
Pendiente
</span>
<?php endif; ?>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<strong>Backups disponibles:</strong>
<span class="badge bg-info"><?php echo esc_html($stats['backups_count']); ?></span>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<strong>Opciones en wp_options:</strong>
<span class="badge bg-secondary"><?php echo esc_html($stats['old_options_count']); ?></span>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<strong>Configs en tabla nueva:</strong>
<span class="badge bg-primary"><?php echo esc_html($stats['new_config_count']); ?></span>
</div>
</div>
</div>
<!-- Progress Bar (si hay migración parcial) -->
<?php if (!$stats['is_migrated'] && $stats['new_config_count'] > 0): ?>
<div class="progress mt-3" style="height: 25px;">
<?php
$total = max($stats['old_options_count'], $stats['new_config_count']);
$percentage = $total > 0 ? ($stats['new_config_count'] / $total) * 100 : 0;
?>
<div class="progress-bar bg-warning" role="progressbar"
style="width: <?php echo esc_attr($percentage); ?>%;"
aria-valuenow="<?php echo esc_attr($percentage); ?>"
aria-valuemin="0"
aria-valuemax="100">
<?php echo esc_html(round($percentage, 1)); ?>%
</div>
</div>
<small class="text-muted">Migración parcial detectada</small>
<?php endif; ?>
</div>
</div>
<!-- Action Buttons Card -->
<div class="card mt-4" style="max-width: 800px;">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">
<i class="bi bi-gear me-2"></i>
Acciones
</h5>
</div>
<div class="card-body">
<?php if (!$stats['is_migrated']): ?>
<!-- Migrate Button -->
<form method="post" style="display: inline;">
<?php wp_nonce_field('apus_migration_action', 'apus_migration_nonce'); ?>
<input type="hidden" name="apus_migrate_action" value="migrate">
<button type="submit" class="btn btn-primary" onclick="return confirm('¿Está seguro de ejecutar la migración? Se creará un backup automático.');">
<i class="bi bi-arrow-right-circle me-1"></i>
Ejecutar Migración
</button>
</form>
<p class="text-muted mt-2 mb-0">
<small>
<i class="bi bi-info-circle me-1"></i>
Se creará un backup automático antes de la migración. Total de configuraciones: <?php echo esc_html($stats['old_options_count']); ?>
</small>
</p>
<?php else: ?>
<div class="alert alert-success mb-0">
<i class="bi bi-check-circle me-2"></i>
La migración ya ha sido completada. Las opciones del tema ahora se leen desde la tabla personalizada.
</div>
<?php endif; ?>
</div>
</div>
<!-- Backups Card -->
<?php if ($stats['backups_count'] > 0): ?>
<div class="card mt-4" style="max-width: 800px;">
<div class="card-header bg-info text-white">
<h5 class="mb-0">
<i class="bi bi-archive me-2"></i>
Backups Disponibles (<?php echo esc_html($stats['backups_count']); ?>)
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Nombre del Backup</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ($stats['backups'] as $backup): ?>
<tr>
<td>
<code><?php echo esc_html($backup); ?></code>
</td>
<td>
<!-- Rollback -->
<form method="post" style="display: inline;" class="me-2">
<?php wp_nonce_field('apus_migration_action', 'apus_migration_nonce'); ?>
<input type="hidden" name="apus_migrate_action" value="rollback">
<input type="hidden" name="backup_name" value="<?php echo esc_attr($backup); ?>">
<button type="submit" class="btn btn-sm btn-warning" onclick="return confirm('¿Está seguro de restaurar este backup? Esto revertirá la migración.');">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Restaurar
</button>
</form>
<!-- Delete -->
<form method="post" style="display: inline;">
<?php wp_nonce_field('apus_migration_action', 'apus_migration_nonce'); ?>
<input type="hidden" name="apus_migrate_action" value="delete_backup">
<input type="hidden" name="backup_name" value="<?php echo esc_attr($backup); ?>">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('¿Está seguro de eliminar este backup?');">
<i class="bi bi-trash me-1"></i>
Eliminar
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<!-- Technical Information -->
<div class="card mt-4" style="max-width: 800px;">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-square me-2"></i>
Información Técnica
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Componente:</dt>
<dd class="col-sm-8"><code>theme</code></dd>
<dt class="col-sm-4">Tabla antigua:</dt>
<dd class="col-sm-8"><code>wp_options</code> (opción: <code>apus_theme_options</code>)</dd>
<dt class="col-sm-4">Tabla nueva:</dt>
<dd class="col-sm-8"><code>wp_apus_theme_components</code></dd>
<dt class="col-sm-4">Versión Admin Panel:</dt>
<dd class="col-sm-8"><code><?php echo esc_html(APUS_ADMIN_PANEL_VERSION); ?></code></dd>
<dt class="col-sm-4">Archivo Helper:</dt>
<dd class="col-sm-8"><code>inc/theme-settings.php</code></dd>
</dl>
</div>
</div>
</div>
<style>
.card {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.card-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
border-radius: calc(0.375rem - 1px) calc(0.375rem - 1px) 0 0;
}
.card-body {
padding: 1rem;
}
</style>

Some files were not shown because too many files have changed in this diff Show More