234 Commits

Author SHA1 Message Date
FrankZamora
85f3387fd2 perf(php): add conditional debug logging to prevent gb logs
- add ROI_DEBUG constant (default false) to control debug output
- create roi_debug_log() function for conditional logging
- replace all error_log DEBUG calls with roi_debug_log
- keep ERROR logs always active for exception tracking
- to enable debug, add define('ROI_DEBUG', true) in wp-config.php

this prevents production logs from growing to gb sizes
(previous error.log was 4.8gb from constant debug output)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:52:50 -06:00
FrankZamora
ff5ba25505 feat(php): implement cache-first architecture hook
Add CacheFirstHooksRegistrar that fires roi_theme_before_page_serve
hook on template_redirect priority 0 for singular pages.

- Only fires for anonymous users (cache doesn't apply to logged in)
- Only fires for singular pages (posts, pages, CPTs)
- Provides post_id to external plugins
- Does NOT define DONOTCACHEPAGE (allows page caching)

Plan 1000.01 implementation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:11:48 -06:00
FrankZamora
eab974d14c docs(config): add cache-first-architecture specification
Define arquitectura cache-first para ROI-Theme:
- Hook roi_theme_before_page_serve en template_redirect p=0
- Solo para páginas singulares y usuarios anónimos
- Permite que plugins externos evalúen acceso antes de servir página
- NO define DONOTCACHEPAGE (permite cache)

Plan 1000.01 - Preparación para integración con IP View Limit.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:07:14 -06:00
FrankZamora
b509b1a2b4 fix(php): toc fallback to raw content when filtered has no headings
When plugins like Thrive Visual Editor transform content for
non-logged users, headings may be removed from the filtered content.
This fix uses raw post_content as fallback when filtered content
has no headings but raw content does.

Also removes temporary debug logging added for diagnosis.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:08:24 -06:00
FrankZamora
83d113d669 chore(php): add more debug for toc heading detection
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:05:51 -06:00
FrankZamora
0c1908e7d1 chore(php): add toc debug logging for guest visibility issue
Temporary debug logging to diagnose why TOC shows for logged users
but not for guests. Logs visibility checks at each layer.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:03:42 -06:00
FrankZamora
5333531be4 fix(templates): add missing components to archive templates
- Add social-share component to category.php and archive.php
- Add related-post component to category.php and archive.php
- Now archive templates have same components as single.php

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:40:53 -06:00
FrankZamora
fb68f2023c fix(theme): improve post-grid spacing, pagination and archive templates
- Fix flexbox gap issue causing unequal horizontal/vertical spacing
- Reset Bootstrap row/col margins to use only CSS gap property
- Replace WordPress pagination with Bootstrap-style pagination
- Add cta-post component to category.php and archive.php templates
- Fix spacing controls UI with separate horizontal/vertical gap fields
- Update FieldMapper with new gap_horizontal and gap_vertical attributes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:26:19 -06:00
FrankZamora
79e91f59ee feat(theme): add [roi_post_grid] shortcode for static pages
- Create PostGridShortcodeRegistrar for WordPress shortcode registration
- Implement RenderPostGridUseCase following Clean Architecture
- Add PostGridQueryBuilder for custom WP_Query construction
- Add PostGridShortcodeRenderer for HTML/CSS generation
- Register shortcode in DIContainer with proper DI
- Add shortcode usage guide in post-grid admin panel
- Fix sidebar layout: add hide_for_logged_in check to wrapper visibility

Shortcode attributes: category, tag, author, posts_per_page, columns,
show_pagination, show_thumbnail, show_excerpt, show_meta, etc.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 21:33:20 -06:00
FrankZamora
c23dc22d76 feat(templates): add archive-header and post-grid components
- Add ArchiveHeader component (schema, renderer, formbuilder)
- Add PostGrid component (schema, renderer, formbuilder)
- Unify archive templates (home, archive, category, tag,
  author, date, search)
- Add page visibility system with VisibilityDefaults
- Register components in AdminDashboardRenderer
- Fix boolean conversion in functions-addon.php
- All 172 unit tests passed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 20:36:27 -06:00
FrankZamora
b79569c5e7 docs: add templates-unificados openspec specification
Defines unified listing templates architecture.

Key additions:
- Two new components: archive-header, post-grid
- 10-phase implementation sequence
- _page_visibility group pattern
- Graceful handling of missing content
- CSS pagination via CSSGeneratorService

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 19:14:09 -06:00
FrankZamora
6be292e085 chore: eliminar related-posts.php legacy (plan 101 fase 3)
- eliminar inc/related-posts.php (reemplazado por relatedpostrenderer)
- eliminar require en functions.php

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 18:17:48 -06:00
FrankZamora
885276aad1 chore: purgar archivos legacy (plan 101 fase 2)
- eliminar 5 template parts reemplazados por renderers
- eliminar header.js (90% codigo muerto)
- eliminar apu-tables-auto-class.js (migrdo a php)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 18:11:04 -06:00
FrankZamora
1e6a076904 chore: purgar archivos no utilizados (plan 101 fase 1)
- eliminar carpetas vacias admin/herosection y bootstrapicons
- eliminar 7 scripts legacy con credenciales hardcodeadas
- eliminar formbuilder duplicado en shared/infrastructure/ui
- eliminar 11 archivos .gitkeep en carpetas con contenido

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 18:09:52 -06:00
FrankZamora
a33c43a104 fix(admin): corregir guardado customcssmanager con toast
- crear bootstrap para handler post en admin_init
- ocultar botones globales para custom-css-manager
- simplificar formbuilder eliminando handler duplicado
- reemplazar alert por toast para notificaciones

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 15:02:06 -06:00
FrankZamora
78d2ba57b9 fix(php): disable zlib compression conflicting with w3tc
Disable roi_enable_gzip_compression() function that was enabling
zlib.output_compression, preventing W3TC from caching pages.
Nginx handles gzip at server level instead.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:55:09 -06:00
FrankZamora
1c0750604b chore(config): remove pre-commit hook that runs non-existent tests
El hook pre-commit ejecutaba npm test, pero el proyecto no tiene
tests configurados. Solo se necesita commit-msg para commitlint.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:00:18 -06:00
FrankZamora
bf304f08fc fix(css): centrar verticalmente contenido del hero section
Agrega propiedades flexbox al contenedor .hero-section para que el
contenido (título y badges) se muestre centrado verticalmente cuando
no hay badges de categorías.

Cambios:
- display: flex
- align-items: center
- justify-content: center

También incluye specs OpenSpec para arquitectura del tema.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 14:44:50 -06:00
FrankZamora
30b30b065b feat: add OpenSpec slash commands for Claude Code
- Remove .claude/ from .gitignore to share commands with team
- Add /openspec:proposal command for creating change proposals
- Add /openspec:apply command for implementing changes
- Add /openspec:archive command for archiving completed changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 12:59:05 -06:00
FrankZamora
b2d5cdfb57 feat: install and configure OpenSpec for spec-driven development
- Add openspec/ directory with AGENTS.md and project.md
- Add root AGENTS.md stub for AI assistants
- Create Claude Code slash commands:
  - /openspec:proposal - Create change proposals
  - /openspec:apply - Implement changes
  - /openspec:archive - Archive completed changes
- Configure project.md with ROI Theme conventions and architecture

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 12:57:08 -06:00
FrankZamora
b40e5b671a chore: backup before OpenSpec installation 2025-12-05 12:51:08 -06:00
FrankZamora
61c67acca5 fix(admin): agregar mapeo hide_for_logged_in en FieldMappers (Plan 99.16)
Sin el mapeo en los FieldMappers, el campo no se guardaba en BD.
Agregado mapeo para: CtaBoxSidebar, CtaLetsTalk, CtaPost, TopNotificationBar.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:40:31 -06:00
FrankZamora
ffe6ea8e65 feat(visibility): añadir opción "Ocultar para usuarios logueados" (Plan 99.16)
- Crear UserVisibilityHelper centralizado en Shared/Infrastructure/Services
- Añadir campo hide_for_logged_in en schemas de 4 componentes
- Integrar validación en Renderers: TopBar, LetsTalk, CTASidebar, CTAPost
- Añadir checkbox UI en FormBuilders de los 4 componentes
- Refactorizar adsense-placement.php para usar el helper centralizado
- Deprecar función roi_should_hide_for_logged_in() (backwards compatible)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:28:53 -06:00
FrankZamora
36d5cf56de fix(wrappers): eliminar wrappers vacíos y corregir exclusiones AdSense (Plan 99.15)
## Problema
- Componentes deshabilitados/excluidos dejaban wrappers HTML vacíos
  (navbar 32px, sidebar col-lg-3 294px)
- AdSense ignoraba exclusiones por URL pattern en grupo _exclusions

## Solución Plan 99.15 (Clean Architecture)

### Domain Layer
- WrapperVisibilityCheckerInterface: contrato para verificar visibilidad

### Application Layer
- CheckWrapperVisibilityUseCase: orquesta verificaciones de visibilidad

### Infrastructure Layer
- WordPressComponentVisibilityRepository: consulta BD + PageVisibilityHelper
- WrapperVisibilityService: facade estático para templates
- BodyClassHooksRegistrar: agrega clases CSS failsafe al body

### Templates modificados
- header.php: renderizado condicional de <nav> wrapper
- page.php/single.php: lógica dinámica col-lg-9/col-lg-12 según sidebar

### CSS Failsafe
- css-global-utilities.css: reglas body.roi-hide-* como respaldo

## Fix AdSense (Inc/adsense-placement.php)
- Agregado PageVisibilityHelper::shouldShow() a todas las funciones:
  roi_render_ad_slot, roi_render_rail_ads, roi_enqueue_adsense_script,
  roi_inject_content_ads, roi_render_anchor_ads, roi_render_vignette_ad,
  roi_enqueue_anchor_vignette_scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 11:46:21 -06:00
FrankZamora
23339e3349 feat(adsense): add exclusion system support (Plan 99.11)
- Add _page_visibility fields to FieldMapper
- Add _exclusions fields to FieldMapper
- Add page visibility checkboxes to FormBuilder
- Add ExclusionFormPartial integration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:37:56 -06:00
FrankZamora
caa6413bc6 Reapply "refactor: remove legacy HeroSection component (orphaned code)"
This reverts commit ea695010f3.
2025-12-03 21:17:04 -06:00
root
ea695010f3 Revert "refactor: remove legacy HeroSection component (orphaned code)"
This reverts commit e4c79d3f26.
2025-12-03 21:11:41 -06:00
FrankZamora
e4c79d3f26 refactor: remove legacy HeroSection component (orphaned code)
BREAKING: Remove unused HeroSectionRenderer and content-hero template

Analysis confirmed HeroSection was legacy orphaned code:
- No JSON schema (hero-section.json didn't exist)
- No FormBuilder for admin UI
- No database records (component_name='hero-section')
- No template usage (single.php uses 'hero' not 'hero-section')
- Violated Clean Architecture (hardcoded CSS)

Removed files:
- Public/HeroSection/Infrastructure/Ui/HeroSectionRenderer.php
- TemplateParts/content-hero.php
- case 'hero-section' from functions-addon.php switch

Active Hero component (Schemas/hero.json + HeroRenderer.php) remains unchanged.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 21:08:25 -06:00
FrankZamora
f4b45b7e17 fix(exclusions): Corregir Renderers que ignoraban sistema de exclusiones
Plan 99.11 - Correcciones críticas:

- FooterRenderer: Añadir PageVisibilityHelper::shouldShow()
- HeroSectionRenderer: Añadir PageVisibilityHelper::shouldShow()
- AdsensePlacementRenderer: Añadir PageVisibilityHelper::shouldShow()

Mejoras adicionales:
- UrlPatternExclusion: Soporte wildcards (*sct* → regex)
- ExclusionFormPartial: UI mejorada con placeholders
- ComponentConfiguration: Grupo _exclusions validado
- 12 FormBuilders: Integración UI de exclusiones
- 12 FieldMappers: Mapeo campos de exclusión

Verificado: Footer oculto en post con categoría excluida SCT

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 19:52:44 -06:00
FrankZamora
c28fedd6e7 feat(exclusions): Integrate exclusion UI in CtaBoxSidebar component
- Add ExclusionFormPartial to CtaBoxSidebarFormBuilder
- Add _exclusions fields to CtaBoxSidebarFieldMapper with types
- Update AdminAjaxHandler to process json_array types via ExclusionFieldProcessor
- Enqueue exclusion-toggle.js in AdminAssetEnqueuer

The exclusion UI now appears in CTA Box Sidebar visibility section,
allowing admins to exclude component from specific categories,
post IDs, or URL patterns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:05:52 -06:00
FrankZamora
14138e7762 feat(exclusions): Implement component exclusion system (Plan 99.11)
Adds ability to exclude components from specific:
- Categories (by slug or term_id)
- Post/Page IDs
- URL patterns (substring or regex)

Architecture:
- Domain: Value Objects (CategoryExclusion, PostIdExclusion,
  UrlPatternExclusion, ExclusionRuleSet) + Contracts
- Application: EvaluateExclusionsUseCase +
  EvaluateComponentVisibilityUseCase (orchestrator)
- Infrastructure: WordPressExclusionRepository,
  WordPressPageContextProvider, WordPressServerRequestProvider
- Admin: ExclusionFormPartial (reusable UI),
  ExclusionFieldProcessor, JS toggle

The PageVisibilityHelper now uses the orchestrator UseCase that
combines page-type visibility (Plan 99.10) with exclusion rules.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:51:00 -06:00
FrankZamora
8735962f52 feat(visibility): sistema de visibilidad por tipo de página
- Añadir PageVisibility use case y repositorio
- Implementar PageTypeDetector para detectar home/single/page/archive
- Actualizar FieldMappers con soporte show_on_[page_type]
- Extender FormBuilders con UI de visibilidad por página
- Refactorizar Renderers para evaluar visibilidad dinámica
- Limpiar schemas removiendo campos de visibilidad legacy
- Añadir MigrationCommand para migrar configuraciones existentes
- Implementar adsense-loader.js para carga lazy de ads
- Actualizar front-page.php con nueva estructura
- Extender DIContainer con nuevos servicios

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 09:16:34 -06:00
FrankZamora
7fb5eda108 refactor(template): unificar page.php con estructura de single.php
- Grid layout col-lg-9 + col-lg-3
- Incluye todos los componentes: hero, featured-image, social-share,
  cta-post, related-post, table-of-contents, cta-box-sidebar, contact-form
- Permite que cta-box-sidebar se muestre en páginas cuando show_on_pages=all

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:36:36 -06:00
FrankZamora
4cdc4db397 fix(css): bump bootstrap-subset version to force cache refresh
Changed version from 5.3.2-subset to 5.3.2-subset-2 to invalidate
browser/CDN cache after removing position:relative from .navbar.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:23:42 -06:00
FrankZamora
c732b5af05 fix(css): remove position:relative from .navbar in bootstrap-subset
Post-process PurgeCSS output to remove position:relative from .navbar,
allowing CriticalCSSService's position:sticky to take effect.

This prevents bootstrap-subset.min.css from overriding the navbar
sticky positioning set by the critical CSS system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:20:37 -06:00
FrankZamora
29a69617e4 fix(navbar): permitir position sticky dinámico
Eliminar position:relative hardcodeado de .navbar en critical-bootstrap.css
para que CriticalCSSService pueda establecer position:sticky según
la configuración sticky_enabled en BD.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:54:47 -06:00
FrankZamora
9e37ea93eb fix(icons): cargar bootstrap-icons como CSS crítico
- Remover bootstrap-icons de ROI_DEFERRED_CSS
- Cambiar media='print' a media='all'
- Solo 4.4KB - no impacta PageSpeed significativamente
- Elimina flash/parpadeo de iconos en carga inicial

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:40:16 -06:00
FrankZamora
7472dbad11 revert: restaurar Poppins - parpadeo de iconos persiste
Revertir cambio a system fonts porque el parpadeo de iconos
(Bootstrap Icons) sigue presente, haciendo el cambio innecesario.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:37:52 -06:00
FrankZamora
ce66eeba6d refactor(fonts): cambiar a system fonts - CERO flash
- Eliminar @font-face de Poppins (critical-bootstrap.css, css-global-fonts.css)
- Actualizar --bs-body-font-family a system font stack
- Desactivar font preload en CriticalCSSInjector.php
- Actualizar bootstrap-subset.min.css

System fonts garantizan carga instantánea sin parpadeo.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:29:01 -06:00
FrankZamora
565c275c16 fix(fonts): size-adjust 106% - verificar comportamiento original 2025-12-02 10:15:37 -06:00
FrankZamora
faf5fc6db2 fix(fonts): size-adjust 105% - calibrando fallback 2025-12-02 10:14:01 -06:00
FrankZamora
de66b77fe3 fix(fonts): ajustar size-adjust a 103% para mejor match con Poppins
- 100.6% era muy pequeño (texto crecia al cargar Poppins)
- 106% era muy grande (texto se achicaba al cargar Poppins)
- 103% es el punto medio para minimizar CLS

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:11:20 -06:00
FrankZamora
73e5ac4acd fix(fonts): forzar Poppins en bootstrap-subset.min.css
El archivo bootstrap-subset.min.css (cargado diferido) sobrescribia
--bs-body-font-family con var(--bs-font-sans-serif), ignorando
la definicion en critical-bootstrap.css.

Cambio:
- --bs-body-font-family: var(--bs-font-sans-serif)
+ --bs-body-font-family: "Poppins","Poppins Fallback",sans-serif

Esto garantiza que Poppins se use en todo el sitio.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:08:29 -06:00
FrankZamora
78ec902688 fix(fonts): sincronizar @font-face en critical-bootstrap.css
El archivo critical-bootstrap.css (TIPO 2) carga primero y tenia
valores desactualizados que causaban el salto visual en navbar.

Cambios:
- font-display: optional → swap
- size-adjust: 106% → 100.6%

Ahora ambos archivos (@font-face) estan sincronizados:
- critical-bootstrap.css (inline P:0)
- css-global-fonts.css (deferred)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:06:04 -06:00
FrankZamora
d8fa5cb609 fix(fonts): eliminar CLS en navbar causado por font swap
- Agregar font preload en P:-2 para fuentes Poppins críticas (400, 600, 700)
- Cambiar font-display: optional → swap para garantizar carga de fuente
- Ajustar size-adjust: 106% → 100.6% para minimizar salto visual
- Nuevo orden prioridades: P:-2 → P:-1 → P:0 → P:1 → P:2 → P:3 → P:5

Archivos:
- CriticalCSSInjector.php: nuevo método preloadFonts()
- css-global-fonts.css: @font-face optimizado

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:01:12 -06:00
FrankZamora
e01605ec37 feat(critical-css): implementar TIPO 4 y TIPO 5 - CSS Below-the-fold y Lazy Loading
## TIPO 4: CSS Below-the-fold (Critical Variables + Responsive)
- Inyecta variables CSS críticas inline en wp_head P:-1
- Inyecta media queries críticas inline en wp_head P:2 (corregido de P:1)
- Auto-regeneración cuando archivos fuente cambian (filemtime check)
- Cache en Assets/CriticalCSS/ para evitar lecturas repetidas
- Comando WP-CLI: wp roi-theme generate-critical-css

Archivos TIPO 4:
- Public/CriticalCSS/Domain/Contracts/ - Interfaces (DIP)
- Public/CriticalCSS/Application/UseCases/GetCriticalCSSUseCase.php
- Public/CriticalCSS/Infrastructure/Cache/CriticalCSSFileCache.php
- Public/CriticalCSS/Infrastructure/Services/CriticalCSSExtractor.php
- Public/CriticalCSS/Infrastructure/Services/CriticalCSSInjector.php
- bin/generate-critical-css.php

## TIPO 5: CSS No Crítico (Lazy Loading)
- Animaciones CSS: carga 2s después de page load via requestIdleCallback
- Print CSS: carga solo al imprimir via beforeprint event
- Fallback <noscript> para usuarios sin JavaScript
- Safari fallback: setTimeout cuando requestIdleCallback no disponible

Archivos TIPO 5:
- Assets/Js/lazy-css-loader.js
- Public/LazyCSSLoader/Infrastructure/Contracts/LazyCSSRegistrarInterface.php
- Public/LazyCSSLoader/Infrastructure/Services/LazyCSSRegistrar.php

## Fix: Colisión de prioridades wp_head
Antes: TIPO 1 (P:1), TIPO 4 responsive (P:1), TIPO 3 (P:2) - CONFLICTO
Después: TIPO 1 (P:1), TIPO 4 responsive (P:2), TIPO 3 (P:3) - OK

Nuevo orden de prioridades:
P:-1 roi-critical-variables (TIPO 4)
P:0  roi-critical-bootstrap (TIPO 2)
P:1  roi-critical-css (TIPO 1)
P:2  roi-critical-responsive (TIPO 4)
P:3  roi-custom-critical-css (TIPO 3)
P:5  roi-theme-layout-css (ThemeSettings)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 23:06:12 -06:00
FrankZamora
e1923b630d refactor(theme-settings): remove CSS card, CSS now managed by CustomCSSManager
- Remove custom_css field from schema (v1.4.0 → v1.5.0)
- Remove buildCssGroup() from FormBuilder
- Remove renderCustomCSS() from Renderer
- Update layout: single JS card instead of 2-column layout
- Update descriptions to reference CustomCSSManager (TIPO 3)

CSS personalizado ahora se gestiona exclusivamente desde el
componente CustomCSSManager, eliminando duplicidad funcional.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:43:12 -06:00
FrankZamora
625d99d698 fix(css-manager): remove debug logging, CSS injection confirmed working
CSS injection is working correctly in production:
- roi-custom-deferred-css appears in page output
- 2 snippets with 24KB of CSS being injected

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:35:55 -06:00
FrankZamora
9f0ae9fcb6 debug: add detailed logging to CustomCSSInjector
Temporary logging to diagnose why CSS is not injecting in production

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:32:43 -06:00
FrankZamora
647f177a35 fix(css-manager): add error logging to debug hook registration
- Wrap CustomCSSManager bootstrap in try-catch
- Log success message when WP_DEBUG is enabled
- Log detailed error with file/line on failure

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:31:02 -06:00
FrankZamora
49eff2223c fix(custom-css-manager): registrar hooks directamente sin wrapper 2025-12-01 16:24:51 -06:00
FrankZamora
c302c653c3 fix(custom-css-manager): cambiar hook de after_setup_theme a wp
El hook after_setup_theme se ejecuta muy temprano, antes de que
WordPress determine el tipo de request. Cambio a 'wp' que se
ejecuta después del query principal.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:21:20 -06:00
FrankZamora
9cb0dd1491 feat(custom-css-manager): implementar TIPO 3 - CSS Crítico Personalizado
Nuevo sistema de gestión de CSS personalizado con panel admin:
- Admin/CustomCSSManager: CRUD de snippets CSS (crítico/diferido)
- Public/CustomCSSManager: Inyección dinámica en frontend
- Schema JSON para configuración del componente

Migración de CSS estático a BD:
- Tablas APU (~14KB) → snippet diferido en BD
- Tablas Genéricas (~10KB) → snippet diferido en BD
- Comentadas funciones legacy en enqueue-scripts.php

Limpieza de archivos obsoletos:
- Eliminado build-bootstrap-subset.js
- Eliminado migrate-legacy-options.php
- Eliminado minify-css.php
- Eliminado purgecss.config.js

Beneficios:
- CSS editable desde admin sin tocar código
- Soporte crítico (head) y diferido (footer)
- Filtrado por scope (all/home/single/archive)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:43:25 -06:00
FrankZamora
423aae062c refactor(css): limpiar critical-bootstrap.css - solo Bootstrap puro
Eliminado CSS personalizado que no pertenece a Bootstrap:
- Site Structure (.site, .site-main)
- Accessibility (.screen-reader-text, .skip-link)
- CLS Prevention Tables APU (.analisis, .desglose)
- CLS Prevention AdSense (ins.adsbygoogle)
- CLS Prevention Navbar Collapse Mobile
- CLS Prevention Hero Section
- CLS Prevention Featured Image
- CLS Prevention Post Content

Reducción: 1107 → 818 líneas (~26% menos)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 14:28:07 -06:00
FrankZamora
972c3c5de9 feat(critical-css): agregar TOC y CTA Let's Talk a CSS crítico
- Agregar campo is_critical a schemas table-of-contents.json y cta-lets-talk.json
- Cambiar generateCSS() de private a public en TableOfContentsRenderer y CtaLetsTalkRenderer
- Registrar table-of-contents y cta-lets-talk en CRITICAL_RENDERERS
- Ahora 6 componentes inyectan CSS crítico inline en <head>

Componentes críticos:
- top-notification-bar
- navbar
- cta-lets-talk (NUEVO)
- hero
- featured-image
- table-of-contents (NUEVO)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 14:08:43 -06:00
FrankZamora
cc4de0eda7 fix(performance): eliminar sistema legacy Critical CSS duplicado
- Eliminado Inc/critical-css.php (381 lineas de codigo legacy)
- Eliminado require en functions.php linea 45
- Sistema unificado: CriticalCSSService.php genera CSS dinamico desde BD
2025-12-01 13:18:21 -06:00
FrankZamora
80fc41afad revert(critical-css): Remove APU tables CSS and restore badge to original
The table-layout: fixed without defined column widths broke table layout.
Reverting to investigate proper solution.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 13:05:20 -06:00
FrankZamora
0b34317cc6 fix(critical-css): Increase badge min-height 32px→36px and add APU tables critical CSS
- Fix hero badge min-height from 32px to 36px (actual height is 35px)
- Add vertical-align: middle to badge for better alignment
- Add critical CSS for .analisis and .desglose tables:
  - table-layout: fixed to prevent reflow
  - overflow-x: auto for mobile horizontal scroll
- These changes target CLS 0.117 caused by hero badges

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:58:38 -06:00
FrankZamora
0ea874876e fix(cls): change bootstrap-icons font-display to optional
Changed font-display from 'swap' to 'optional' in bootstrap-icons
subset CSS to prevent CLS caused by icon font swap during page load.

Fixes CLS 0.115 on hero badges with bi-folder-fill icon.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:47:59 -06:00
FrankZamora
fb74ccbdc2 fix: Change font-display from swap to optional to eliminate CLS
font-display: swap causes layout shift when fonts load and replace fallback.
font-display: optional prevents CLS entirely - if font doesn't load in ~100ms,
fallback is used permanently. With preload, fonts should load in time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:26:33 -06:00
FrankZamora
9f5cc92ec6 fix: Add critical CSS for featured-image and post-content to prevent CLS
- .featured-image-container: aspect-ratio 16/9 reserves space before image loads
- .post-content: min-height prevents layout shift
- Targets PageSpeed CLS issues: main-content 0.121, container 0.099

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:19:23 -06:00
FrankZamora
c6450211a7 fix: Rename Assets/css to Assets/Css, Assets/js to Assets/Js in git
Windows case-insensitive but Linux case-sensitive.
Git was tracking lowercase, causing 404s on server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:13:24 -06:00
FrankZamora
3c8e5982ba fix: Update all paths to PascalCase (Css, Js, Fonts)
- Update enqueue-scripts.php: /Assets/css/ → /Assets/Css/, /Assets/js/ → /Assets/Js/
- Update related-posts.php, performance.php, minify-css.php, CSSConflictValidator.php
- Fix 404 errors for CSS/JS/Fonts on production

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:06:28 -06:00
FrankZamora
7667b7f02a refactor: Rename fonts to Fonts (PascalCase consistency)
- Rename Assets/fonts/ to Assets/Fonts/
- Update all references in PHP and CSS files
- Consistent with Css, Js, Vendor naming

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 11:40:45 -06:00
FrankZamora
c4dcdad14b fix(cls): load css-tablas-apu.css async to prevent render blocking
- Add complete APU table column widths to critical-bootstrap.css
- Load css-tablas-apu.css with media="print" + onload for async loading
- Critical styles (column widths, row backgrounds) now inline in <head>
- Full CSS loads non-blocking after initial render

Fixes PageSpeed "Render-blocking CSS" warning (120ms savings)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 11:28:53 -06:00
FrankZamora
d648e7ff4c fix(CLS): move APU table row classes from JS to PHP server-side
PROBLEM:
- apu-tables-auto-class.js was adding CSS classes to table rows after DOMContentLoaded
- This DOM manipulation caused CLS of 0.692 in Lab Data (PageSpeed Insights)
- body.wp-singular was the main culprit with 0.692 CLS contribution

SOLUTION:
- Added roi_add_apu_row_classes() function in Inc/apu-tables.php
- This applies the same logic server-side during the_content filter
- Classes (section-header, subtotal-row, total-row) are now in HTML from first render
- Disabled the JS script in Inc/enqueue-scripts.php

This should significantly reduce Lab Data CLS (target: < 0.1)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 11:12:34 -06:00
FrankZamora
842f529816 fix(cls): APU tables layout + hero badges min-height
- APU tables: Change media='print' to 'all' for immediate CSS loading
- APU tables: Add table-layout: fixed to prevent column reflow
- Hero: Add min-height: 40px to badge container to reserve space

These changes prevent CLS caused by:
1. Delayed APU table CSS causing table layout shift
2. Hero category badges appearing after page load

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 11:03:23 -06:00
FrankZamora
3b9a1cb299 fix(cls): Preload Poppins fonts to prevent font swap CLS
- Agrega preload de fuentes criticas (regular, 600) en wp_head priority 1
- Fuentes disponibles antes de que CSS las necesite
- Elimina flash de fuente de respaldo que causa layout shift

CLS body.wp-singular esperado: 0.171 -> ~0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 10:51:52 -06:00
FrankZamora
c0172467b3 fix(cls): Server-side device visibility + aspect-ratio for featured-image
- functions-addon.php: Validacion centralizada con wp_is_mobile()
  Componentes con show_on_mobile=false NO se renderizan en mobile
  Previene CLS de elementos ocultos con CSS

- FeaturedImageRenderer: Agrega aspect-ratio 16/9 para reservar espacio
  Imagen usa object-fit:cover con position:absolute
  Metodo generateCSS() ahora publico para CriticalCSSService

- CriticalCSSService: Agrega featured-image a CRITICAL_RENDERERS
  CSS se inyecta en <head> antes de que cargue contenido

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 10:43:22 -06:00
FrankZamora
ee28baafd8 fix(cls): Add inline CSS to prevent navbar-collapse CLS on mobile
Adds minimal CSS in <head> before wp_head() to hide navbar-collapse
on mobile before Bootstrap loads. This prevents the 0.245 CLS caused
by Bootstrap calculating dimensions on an element that should be hidden.

The CSS in critical-bootstrap.css was arriving too late (via wp_head)
to prevent the initial layout shift.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 23:36:22 -06:00
FrankZamora
d145d4dfde revert: remove contain:layout - caused navbar CLS increase
contain:layout on main-content fixed AdSense CLS but broke
navbar-collapse causing 0.575 CLS (total 0.887 vs 0.583 before)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 23:28:42 -06:00
FrankZamora
8710895db5 fix(cls): add contain:layout to prevent AdSense layout shifts
AdSense injects style="height: auto !important" to main-content
causing CLS 0.354. contain:layout isolates from external re-layouts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 23:26:21 -06:00
FrankZamora
163b8c6c2a Revert "fix(cls): add inline CSS to prevent navbar-collapse layout shift"
This reverts commit 0239191dfc.
2025-11-30 23:05:59 -06:00
FrankZamora
0239191dfc fix(cls): add inline CSS to prevent navbar-collapse layout shift
Add CSS rule to hide navbar-collapse before Bootstrap JS loads.
This prevents Bootstrap from calculating dimensions on an element
that will be hidden anyway, eliminating 0.245 CLS on mobile.

Target: reduce CLS from 0.479 to ~0.234

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 23:02:55 -06:00
FrankZamora
3bf40787ad fix(cls): remove invalid ScrollSpy from body element
Remove data-bs-spy, data-bs-target, and data-bs-offset attributes
from body tag in header.php. The target element .toc-container does
not exist, causing Bootstrap ScrollSpy to fail and trigger layout
recalculations that account for 82% of the CLS score (0.946 of 1.147).

Ref: 99.02-investigacion-cls-mobile.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 22:55:00 -06:00
FrankZamora
bc85854453 fix(CLS): Prevenir layout shifts de AdSense y navbar móvil
- Reservar min-height para contenedores AdSense (250px en contenido)
- Navbar collapse posición absoluta en móvil para no empujar contenido
- Hero section min-height 120px

PageSpeed CLS issues addressed:
- body CLS 1.000 (desktop) → AdSense inyectaba height:auto
- navbar-collapse CLS 0.245 (móvil) → menú empujaba contenido
- hero-section CLS 0.033 → sin altura reservada

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 14:03:28 -06:00
FrankZamora
4e99fa5310 fix(cls): Eliminar min-height:50vh que causaba CLS masivo
El min-height:50vh en .site-main causaba un CLS de ~1.5 porque:
- Reservaba 50% del viewport inicialmente
- Cuando el contenido real cargaba, generaba un shift enorme

.site-main ya tiene flex-grow:1 que es suficiente para el layout.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 13:43:30 -06:00
FrankZamora
13e17a7b12 fix: Corregir path case-sensitive para Linux
El path a critical-bootstrap.css usaba 'css' (minúscula) pero
el directorio real es 'Css' (mayúscula). Esto causaba que el
CSS crítico no se cargara en producción (Linux es case-sensitive).

Bug encontrado: El CriticalBootstrapService no inyectaba el CSS
en producción porque file_exists() retornaba false.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 13:38:34 -06:00
FrankZamora
c7e8f14d83 perf: Optimización PageSpeed - Score 81→97
Cambios implementados:

1. CSS Crítico (critical-bootstrap.css):
   - Agregar clases responsive d-lg-none, d-lg-block, d-lg-flex
   - Prevenir CLS en TopNotificationBar al ocultar en móvil
   - Agregar estilos para tablas y main content (CLS fix)

2. Google Analytics Diferido (adsense-placement.php):
   - GA4 ahora carga después de 3s o primera interacción
   - Reduce ~59 KiB de JavaScript bloqueante
   - TBT mejorado significativamente

3. CSS Minificado (enqueue-scripts.php):
   - Usar style.min.css y css-global-accessibility.min.css
   - Ahorro ~6 KiB en transferencia

4. Nuevo script minify-css.php para generar versiones .min.css

Resultados PageSpeed Mobile:
- Performance: 81 → 97 (+16 puntos)
- FCP: 2.8s → 1.0s (-64%)
- LCP: 3.5s → 1.3s (-63%)
- Core Web Vitals: SUPERADA

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 13:23:20 -06:00
FrankZamora
0fba2d567c feat: add .btn to critical-bootstrap.css for navbar CTA
Bootstrap .btn class used above-the-fold in navbar needs to be
in critical CSS to prevent CLS when Bootstrap is deferred.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 12:34:36 -06:00
FrankZamora
31d4a41fc9 revert: restore style.css as blocking CSS to fix CLS
- Remove roi-main-style from deferred list
- Restore media='all' for style.css
- CLS was 0.392 when deferred, should return to ~0.06

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 12:26:31 -06:00
FrankZamora
9afdd6ee1d perf: defer style.css - eliminate last render-blocking CSS
- Add site structure, accessibility, typography to critical-bootstrap.css
- Defer roi-main-style (style.css) with media='print'
- Goal: 0 blocking CSS files (was 1 file, 19KB)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 12:19:26 -06:00
FrankZamora
b4071bf598 perf: Defer fonts.css and variables.css, inline critical CSS
- Add @font-face declarations to critical-bootstrap.css inline
- Add critical CSS variables (colors, fonts) inline
- Defer fonts.css (utilities only, @font-face inline)
- Defer variables.css (critical vars inline)

Now only style.css is render-blocking (~19KB)
All other CSS deferred with media=print + onload

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 12:12:57 -06:00
FrankZamora
62a0f17b21 perf: Defer Bootstrap with inline critical CSS for LCP optimization
- Add grid system (row, col-*) to critical-bootstrap.css to prevent CLS
- Add text utilities, sizing, spacing, and alert component to critical CSS
- Enable CriticalBootstrapService to inline critical Bootstrap in <head>
- Defer bootstrap-subset.min.css (21KB) via media=print + onload
- Fix preload pointing to wrong Bootstrap file (was 227KB, now 147KB)

Expected improvement: ~970ms reduction in render-blocking CSS

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 12:05:50 -06:00
FrankZamora
5d4523e49a fix: add centering classes for toast (start-50, translate-middle-x)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:37:02 -06:00
FrankZamora
19b6c38fbf fix: add toast classes to Bootstrap subset safelist
The toast from IP View Limit plugin uses Bootstrap classes that weren't
being detected because PurgeCSS only scans theme files, not plugins.

Added to safelist: toast-container, toast, toast-body, position-fixed,
bottom-0, end-0, text-dark, bg-warning, btn-close, m-auto

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:34:59 -06:00
FrankZamora
8a9c62e17e fix(bootstrap): Agregar clases toast al Bootstrap subset
El toast de "consultas restantes" no se mostraba porque las clases
.toast* fueron eliminadas por PurgeCSS. Agregado /toast/ al safelist.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:30:58 -06:00
FrankZamora
b7ae8cac21 perf(bootstrap): Reduce Bootstrap CSS de 227KB a 145KB con PurgeCSS
- Genera bootstrap-subset.min.css con solo clases usadas (36% reduccion)
- Actualiza enqueue-scripts.php para usar el subset
- Agrega scripts de build: npm run build:bootstrap
- Mejora LCP al reducir tiempo de parseo CSS

Impacto estimado:
- Bootstrap: 227KB -> 145KB (-82KB)
- Tiempo parseo CSS: ~800ms -> ~500ms
- LCP: mejora esperada ~300-500ms

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:26:18 -06:00
FrankZamora
371af1f7e5 feat(toc): auto-scroll del sidebar al elemento activo
Cuando el ScrollSpy detecta un nuevo heading activo,
el TOC sidebar ahora hace scroll automático para
mostrar el elemento activo usando scrollIntoView
con behavior: smooth y block: nearest.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:19:30 -06:00
FrankZamora
a01ebf303e fix(toc): eliminar outline azul en focus de links
Agrega outline: none para .toc-link:focus via CSSGenerator
para remover el borde azul del browser al hacer clic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:14:03 -06:00
FrankZamora
8361e14862 fix(toc): inyectar IDs de headings via JavaScript
Problema: Los headings no tenían atributo id porque el filtro
PHP the_content se agregaba después de procesar el contenido.

Solución: El script del TOC ahora:
1. Busca cada link del TOC
2. Encuentra el heading correspondiente por texto
3. Asigna el ID esperado al heading
4. Luego configura smooth scroll e IntersectionObserver

Esto resuelve:
- Links del TOC no clickeables
- Smooth scroll no funcionaba
- ScrollSpy no rastreaba secciones

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 11:07:57 -06:00
FrankZamora
77a59d0db8 fix(topbar): aplicar clases de visibilidad responsive
El método getVisibilityClasses() existía pero no se usaba.
Ahora buildClasses() verifica show_on_desktop y show_on_mobile
para aplicar clases Bootstrap (d-none d-lg-block, d-lg-none, etc.)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 10:53:29 -06:00
FrankZamora
6004420620 fix: eliminate forced reflows in TOC ScrollSpy + revert Bootstrap defer
- Replace scroll event listener with Intersection Observer in TableOfContentsRenderer
- Eliminates ~100ms forced reflows from offsetTop reads during scroll
- Revert Bootstrap CSS to blocking (media='all') - deferring caused CLS 0.954
- Keep CriticalBootstrapService available for future optimization
- Simplify CriticalCSSHooksRegistrar to only use CriticalCSSService

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 10:52:25 -06:00
FrankZamora
d5a2fd2702 perf: defer Bootstrap CSS with critical subset inline
- Created Assets/css/critical-bootstrap.css (~10KB subset)
  Contains only Bootstrap classes used in above-the-fold components:
  container, navbar, flexbox, dropdown, spacing utilities

- Created CriticalBootstrapService (singleton)
  Injects minified critical Bootstrap in <head> at priority 0
  Output: <style id="roi-critical-bootstrap">...</style>

- Modified enqueue-scripts.php
  Bootstrap now loads with media="print" + onload="this.media='all'"
  Full 31KB Bootstrap loads async, doesn't block rendering

- Updated CriticalCSSHooksRegistrar
  Now registers both CriticalBootstrapService (priority 0)
  and CriticalCSSService (priority 1)

Flow:
1. wp_head (priority 0) → Critical Bootstrap (~10KB inline)
2. wp_head (priority 1) → Critical Component CSS (~4KB inline)
3. Bootstrap full (31KB) loads deferred, non-blocking

Expected PageSpeed improvement: ~400-600ms LCP reduction

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 10:26:24 -06:00
FrankZamora
ce0179a134 feat: implement is_critical CSS injection via CriticalCSSService
- Created CriticalCSSService (singleton) that queries BD directly in wp_head
- Service generates CSS BEFORE components render (priority 1)
- Renderers check is_critical flag and skip inline CSS if true
- Made generateCSS() public in Renderers for CriticalCSSService to use
- Removed CriticalCSSCollector pattern (timing issue with WordPress)

Flow:
1. wp_head (priority 1) → CriticalCSSService::render()
2. Service queries BD for components with visibility.is_critical=true
3. Generates CSS using Renderer->generateCSS() methods
4. Outputs: <style id="roi-critical-css">...</style>
5. When Renderers execute, they detect is_critical and omit CSS inline

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 10:06:38 -06:00
FrankZamora
38d7099bcd fix(renderers): corregir timing issue en CSS crítico
PROBLEMA:
- wp_head() se ejecuta ANTES de que los componentes rendericen
- CriticalCSSCollector estaba vacío al momento de inyectar en <head>
- Componentes retornaban solo HTML sin CSS → sitio sin estilos

SOLUCIÓN:
- Eliminar lógica condicional de is_critical en render()
- Siempre incluir CSS inline con el componente (comportamiento anterior)
- Campo is_critical reservado para futura implementación con output buffering

Archivos modificados:
- NavbarRenderer.php
- TopNotificationBarRenderer.php
- HeroRenderer.php

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 09:42:18 -06:00
FrankZamora
4f25297f14 feat(pagespeed): implementar campo is_critical para CSS crítico dinámico (Phase 4.2)
Implementación completa del sistema de Critical CSS dinámico según plan 13.01:

Domain Layer:
- Crear CriticalCSSCollectorInterface para DIP compliance

Infrastructure Layer:
- Implementar CriticalCSSCollector (singleton via DIContainer)
- Crear CriticalCSSHooksRegistrar para inyección en wp_head
- Actualizar DIContainer con getCriticalCSSCollector()

Schemas:
- Agregar campo is_critical a navbar, top-notification-bar, hero
- Sincronizar con BD (18+39+31 campos)

Renderers (navbar, top-notification-bar, hero):
- Inyectar CriticalCSSCollectorInterface via constructor
- Lógica condicional: si is_critical=true → CSS a <head>

Admin (FormBuilders + FieldMappers):
- Toggle "CSS Crítico" en sección visibility
- Mapeo AJAX para persistencia

Beneficios:
- LCP optimizado: CSS crítico inline en <head>
- Above-the-fold rendering sin FOUC
- Componentes configurables desde admin panel

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 09:29:45 -06:00
FrankZamora
6d03076032 feat(admin): migrar navegación de tabs a cards agrupados
- Implementar sistema de grupos de componentes tipo "carpetas de apps"
- Crear ComponentGroupRegistry para gestionar grupos y componentes
- Añadir vista home con grupos: Header, Contenido, CTAs, Engagement, Forms, Config
- Rediseñar UI con Design System: header navy, cards blancos, mini-cards verticales
- Incluir animaciones fadeInUp escalonadas y efectos hover con glow
- Mantener navegación a vistas de componentes individuales

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 09:10:32 -06:00
FrankZamora
f5089724c6 perf(fonts): cambiar font-display de block a swap en Bootstrap Icons
Optimización PageSpeed Fase 4.1:
- Cambio font-display:block → font-display:swap en bootstrap-icons.min.css
- Reduce bloqueo de renderizado en ~420ms
- Permite mostrar fallback mientras carga la fuente de iconos

Archivos subset ya tenían swap configurado (sin cambios necesarios)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 08:10:13 -06:00
FrankZamora
956819cf14 fix(anchor): Agregar width 100% a .roi-anchor-content
El ins.adsbygoogle tenia width 0 porque el contenedor padre
no tenia ancho definido. Esto causaba el error
"No slot size for availableWidth=0" de AdSense.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:53:28 -06:00
FrankZamora
46ad8340c3 fix(adsense): Anchor/Vignette solo visibles cuando AdSense llena slot
- Anchor Ads: Cambiar de visibility:hidden a transform:translateY()
  para que AdSense pueda medir dimensiones del slot
- Vignette Ads: Solo mostrar overlay cuando data-ad-status="filled"
- Mover card Exclusiones a columna izquierda en admin

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:44:05 -06:00
FrankZamora
4294a7c07b fix(rail): Add 15px margin to prevent hero overlap 2025-11-28 21:34:31 -06:00
FrankZamora
8aba07fdbf fix(vignette): Tamaños 16:9 para video + sin botón cerrar
- Agregados tamaños video: 1280x720, 960x540, 854x480, 800x450, 640x360, 560x315
- Eliminado botón de cerrar (usuario cierra haciendo clic fuera)
- Default cambiado a 960x540 (qHD)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:29:41 -06:00
FrankZamora
13beaf7b06 fix(adsense): Anchor ocultos por defecto + más tamaños Vignette
Anchor Ads:
- Ocultos por defecto via CSS (opacity: 0, visibility: hidden)
- Solo se muestran cuando AdSense llena el slot (clase .ad-loaded)
- Ya no aparece espacio en blanco si no hay anuncio

Vignette Ads:
- Agregados tamaños: 728x90, 970x250, 970x90, 468x60, 320x100
- Nueva opción "auto" (recomendado) para formato automático
- Renderer actualizado para manejar todos los tamaños

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:25:54 -06:00
FrankZamora
1f0ce58b22 fix(adsense): Add string casts to buildSelect() calls in Vignette section
Fix TypeError: buildSelect() Argument #3 ($value) must be of type string.
The getFieldValue() method can return boolean for certain DB values,
causing type mismatch with strict typed buildSelect() method.

Added (string) casts to all buildSelect() calls in Vignette Ads section:
- vignetteTrigger
- size
- opacity
- reshowTime
- maxSession

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:20:12 -06:00
FrankZamora
7edddada89 fix(adsense): ocultar anchor ads cuando AdSense no llena el slot
- Agregar watchUnfilledAds() con MutationObserver
- Detectar data-ad-status="unfilled" de AdSense
- Timeout de 5s como respaldo si no hay contenido
- Evitar mostrar espacio en blanco cuando no hay anuncio

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:10:40 -06:00
FrankZamora
b96a13427e feat(adsense): implementar Anchor Ads y Vignette Ads
- Anchor Ads: anuncios fijos top/bottom con botones minimizar/cerrar
- Vignette Ads: modal fullscreen con triggers configurables
- Schema v1.3.0 con grupos anchor_ads y vignette_ads (18 campos)
- FieldMapper actualizado para persistir settings en BD
- JavaScript para interacción (colapso, cierre, localStorage)
- Soporte para responsive y tamaños fijos en vignette

IMPORTANTE: Ejecutar en servidor remoto:
wp roi-theme sync-component adsense-placement

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:00:00 -06:00
FrankZamora
4d5cc1a58c feat(adsense): agregar opcion para ocultar anuncios a usuarios logueados
- Nuevo campo hide_for_logged_in en schema visibility group
- Switch en panel de administracion con icono bi-person-lock
- Mapeo en FieldMapper para persistencia
- Validacion en roi_render_ad_slot, roi_render_rail_ads,
  roi_enqueue_adsense_script y roi_inject_content_ads
- No carga script ni muestra ads si usuario tiene sesion activa

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 10:31:49 -06:00
FrankZamora
e3d17db5ea cleanup: remove after-related debug code 2025-11-27 22:52:25 -06:00
FrankZamora
a281448bf8 debug: add after-related slot debug comments 2025-11-27 22:50:56 -06:00
FrankZamora
8c3fea964d feat(adsense): agregar slot after-related en single.php
- El slot after-related estaba definido en schema pero no se llamaba
- Ahora se renderiza después del componente related-post
- Requiere habilitar 'Después de Related Posts' en panel AdSense

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 22:45:40 -06:00
FrankZamora
cec8b8dccd fix(rail-ads): rail derecho right:0px 2025-11-27 22:36:25 -06:00
FrankZamora
e8ead33311 fix(rail-ads): rail izquierdo left:0px para evitar corte 2025-11-27 22:34:47 -06:00
FrankZamora
9e8ffdb26f fix(rail-ads): agregar padding y max-width para evitar desbordamiento 2025-11-27 22:24:56 -06:00
FrankZamora
ec64ea38ea fix(rail-ads): alinear ads hacia el contenido - izq flex-end, der flex-start 2025-11-27 22:20:59 -06:00
FrankZamora
e7fc0f1408 fix(rail-ads): agregar overflow:hidden para forzar ancho calculado 2025-11-27 22:18:47 -06:00
FrankZamora
f4e3a61df8 fix(rail-ads): quitar min-width que causaba ancho incorrecto 2025-11-27 22:14:22 -06:00
FrankZamora
961f663107 fix(rail-ads): corregir rail izq cortado y ocultar rails cerca del footer
- Agregar min-width: 170px para evitar corte
- Quitar overflow:hidden que cortaba contenido
- Detectar footer y ocultar rails cuando se acercan
- Transicion suave de opacity para ocultar/mostrar
2025-11-27 22:11:02 -06:00
FrankZamora
21ac98c969 fix(rail-ads): revertir a anuncio 160px fijo centrado en container responsive 2025-11-27 22:07:04 -06:00
FrankZamora
de4f808a1a debug: add logging to rail ads rendering 2025-11-27 22:03:05 -06:00
FrankZamora
c9c6a5ac7b fix(rail-ads): bajar umbral media query de 1620px a 1400px 2025-11-27 21:59:50 -06:00
FrankZamora
6b6ebd3c6d feat(rail-ads): rails responsive que llenan el espacio disponible
- Ancho calculado automaticamente: (viewport - container) / 2 - 20px
- 10px margen del viewport + 10px gap al container
- Selector simplificado: solo altura (250-1050px)
- AdSense usa data-full-width-responsive para adaptarse
- Media query actualizada: oculta en pantallas < 1620px
2025-11-27 21:55:04 -06:00
FrankZamora
070ee7398c fix(rail-ads): cambiar posicionamiento de 15px a 5px del borde del viewport 2025-11-27 21:48:01 -06:00
FrankZamora
ce19345f78 feat(rail-ads): Add more format options with multiple widths
- Added width options: 130px, 140px, 150px, 160px, 300px, 400px, 500px, 600px
- Each width has multiple height options (300, 400, 500, 600, etc.)
- Total of 31 format combinations available
- Updated Schema, Renderer and FormBuilder

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:44:18 -06:00
FrankZamora
1b9910165b fix(rail-ads): Position rails fixed at viewport edges
- Changed Rail Ads positioning from container-width-based formula to fixed 15px from viewport edges
- Rails no longer move inward when container width is reduced
- Fixes overlap issue when layout width setting changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:38:04 -06:00
FrankZamora
6e2ef67dc4 chore: bump css-global-responsive version to 1.1.0 for cache bust
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:32:30 -06:00
FrankZamora
72ef7580fc fix: Container width setting now applies correctly + Rail Ads improvements
- Fix container width not applying: css-global-responsive.css now uses
  CSS variable --roi-container-width instead of hardcoded values
- Add 8 Rail format options: slim-small (160x300), slim-medium (160x400),
  slim-large (160x500), skyscraper (160x600), slim-xlarge (160x700),
  wide-skyscraper (160x800), half-page (300x600), large-skyscraper (300x1050)
- Change rail_top_offset from text input to select with preset values
- Fix Rail Ads JavaScript positioning (moved after HTML, added retries)
- ThemeSettingsRenderer now always outputs CSS variables for layout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:30:06 -06:00
FrankZamora
122bcd4750 feat(adsense): agregar mas opciones de formato y altura para Rail Ads
- Formatos: skyscraper (160x600), wide-skyscraper (160x800), half-page (300x600), large-skyscraper (300x1050)
- Distancia desde arriba: 150, 200, 300 (default), 400, 500, 700px
- Cambiar rail_top_offset de text a select con opciones predefinidas

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:05:49 -06:00
FrankZamora
0dfe3fcd2c feat(theme-settings): agregar configuracion de ancho del contenedor
- Nuevo grupo 'Layout y Contenedor' en theme-settings
- Opciones: 1140px, 1200px, 1320px (default), 1400px, 100%
- CSS dinamico aplicado via ThemeSettingsRenderer
- Corregir selectores de hero en Rail Ads (.hero-section, .featured-image-container)
- Agregar setTimeout para esperar carga de DOM
- Aumentar gap de separacion a 30px
- Subir maxTop al 40% del viewport

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:03:43 -06:00
FrankZamora
2fa112ab7f fix(adsense): corregir posicionamiento de Rail Ads
- Usar CSS max() para evitar rail izquierdo cortado fuera del viewport
- Agregar JavaScript inteligente para detectar navbar/hero dinámicamente
- Rail ads se posicionan debajo del hero cuando es visible
- Usar requestAnimationFrame para throttle de scroll
- Eliminar dependencia de valores fijos en pixels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:55:18 -06:00
FrankZamora
55f061df67 feat(adsense): reorganizar panel con UX mejorada y soporte 1-8 ads random
Panel AdSense reorganizado:
- Diagrama visual mostrando ubicaciones de anuncios (POST-TOP, IN-CONTENT, POST-BOTTOM, RAIL)
- Secciones colapsables por ubicación con badges de color
- Slots con descripciones claras indicando uso (Auto, In-Article, Display, etc.)

In-Content Ads mejorado:
- Soporte para 1-8 anuncios dentro del contenido
- Modo aleatorio (random) que varía posiciones en cada visita
- Configuración de mínimo/máximo de ads
- Párrafos mínimos entre anuncios configurable (2-6)
- Primer ad siempre en posición fija configurada

Archivos modificados:
- Schema v1.2.0 con 4 nuevos campos (random_mode, min_ads, max_ads, min_paragraphs_between)
- FormBuilder con diagrama visual y mejor organización
- ContentAdInjector con lógica de posicionamiento random
- Renderer con soporte para post-content-1 hasta post-content-8
- FieldMapper actualizado con nuevos campos

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:45:40 -06:00
FrankZamora
1a069a1336 fix(adsense): Remove debug code after finding root cause
Root cause: mu-plugin allow-unfiltered-html.php was replacing all
filtered content with raw DB content at PHP_INT_MAX priority.

Solution: Modified mu-plugin on server to call roi_inject_content_ads()
after getting raw content.

NOTE: The mu-plugin change is only on the production server at:
/wp-content/mu-plugins/allow-unfiltered-html.php

The change adds roi_inject_content_ads() call after YouTube Facade filter.
This allows ads to be injected into posts even with the raw content bypass.

Cleaned up:
- Removed debug logging from adsense-placement.php
- Removed template-level debug from single.php
- Removed debug markers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:19:43 -06:00
FrankZamora
cfcc38c0f7 debug: Add template-level debug to trace content output 2025-11-27 20:12:31 -06:00
FrankZamora
c564ee7a2a debug: Add visible marker to trace content loss 2025-11-27 20:09:47 -06:00
FrankZamora
4119f2e86d fix(adsense): Disable output buffer causing content loss
The output buffer in adsense-delay.php was causing conflicts with
zlib compression buffer, resulting in ads being generated but not
appearing in final HTML.

Root cause: Multiple output buffers (zlib + adsense-delay) were
nested improperly, causing ob_end_flush() failures and content loss.

Solution: Disable the output buffer since AdsensePlacementRenderer
already generates scripts with type="text/plain" data-adsense-push.
The buffer was redundant and only needed for external AdSense sources.

Debug logs confirmed:
- Filter generates ads correctly (598+601 chars)
- Content exists after filter (54765 chars)
- But ads missing in final HTML (0 roi-ad-slot found)
- ob_end_flush() errors in debug.log

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:07:09 -06:00
FrankZamora
e52df682ae fix(adsense): Use adsense-placement settings instead of non-existent adsense-delay component
- Change roi_delay_adsense_scripts() to read from 'adsense-placement' component
- Change roi_add_adsense_init_script() to use same settings source
- Use 'forms.delay_enabled' field path instead of 'visibility.is_enabled'
- Add clarifying comments about output buffer purpose

The 'adsense-delay' component was never created in the database, causing
the delay functions to always use default values. Now properly reads
from the existing 'adsense-placement' component settings.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:00:23 -06:00
FrankZamora
58a4cc2c56 fix: Increase the_content filter priority to 150
Changed AdSense injection filter from priority 100 to 150 to ensure
it runs AFTER restrict-content-pro's rcp_filter_restricted_content
filter which also uses priority 100.

This should fix ads not appearing in post content.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:55:13 -06:00
FrankZamora
b9b21c390a debug: Add more logging including post_id and postTopHtml preview
Additional debug info:
- Log post_id and URL to identify which post is being processed
- Log first 200 chars of postTopHtml to verify content
- Log final content length

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:50:53 -06:00
FrankZamora
22e9273b4f debug: Add logging to diagnose AdSense ads not injecting
Added error_log statements to roi_inject_content_ads() to trace:
- Function entry point
- Conditions failing (is_single, in_the_loop, is_main_query)
- Container null check
- Settings loaded status
- Post exclusion check
- Rendered slot lengths
- Any exceptions with full trace

This will help identify why ads are not appearing on production
despite all database settings being correctly configured.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:42:17 -06:00
FrankZamora
79b48ad94f fix(di): declare $container as global before assignment
The $container variable was being assigned inside a try-catch block
without being declared as global first, making it unavailable to
Inc/adsense-placement.php functions. This caused AdSense slots to
not be injected into the content.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:35:22 -06:00
FrankZamora
82abdf047a ui(theme-settings): split into two cards (CSS left, JS right)
- CSS Personalizado card on left column (10 rows textarea)
- JavaScript Personalizado card on right column (Header + Footer)
- Improved visual organization with separate alerts per card

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 18:47:13 -06:00
FrankZamora
b70e11be62 refactor: move Analytics from ThemeSettings to AdsensePlacement
- Remove Analytics and AdSense tabs from theme-settings component
- Add Analytics group to adsense-placement component
- Add roi_enqueue_analytics_script() for GA4/UA support
- Clean up ThemeSettings to only handle custom code (CSS/JS)
- Update FormBuilders and FieldMappers accordingly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 18:43:39 -06:00
FrankZamora
3279b7df2b Add WordPress posts malformed lists fixer for post_content field 2025-11-27 18:10:37 -06:00
FrankZamora
a3fa5fe22e Add specific cases test script 2025-11-27 17:46:51 -06:00
FrankZamora
84441af9c0 Add varied cases finder script 2025-11-27 17:45:55 -06:00
FrankZamora
651e8124d4 Add validation script for list fixes
Generates HTML files for visual comparison before/after
correction. Creates comparison_report.html for review.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:40:18 -06:00
FrankZamora
a10831e2c2 Add DOMDocument-based malformed lists fixer
Robust HTML list structure correction using PHP DOM parser.
Three modes: scan (detect), test (preview), fix (apply).
Properly moves nested lists inside parent <li> elements.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:34:27 -06:00
FrankZamora
d7c42f26ef Add test script for malformed lists fix validation
Demonstrates proposed regex replacement pattern without
applying changes. For validation before mass update.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:29:47 -06:00
FrankZamora
4dbf73f226 Fix column name in malformed lists scanner
Changed 'url' to 'page' to match actual table schema
in preciosunitarios_seo.datos_seo_pagina

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:24:47 -06:00
FrankZamora
f4bd013271 Add diagnostic script for malformed HTML lists
Phase 4.4 Accessibility: Script to scan database for posts
with invalid list structures (<ul> containing non-<li> children).
Read-only analysis, no modifications.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:20:00 -06:00
FrankZamora
371995d151 fix(accessibility): Add main landmark and fix cta-box-title heading
Phase 4.4 Accessibility fixes:
- single.php: Add <main id="main-content" role="main"> landmark
- CtaBoxSidebarRenderer: h5 cta-box-title changed to span

Fixes: "No main landmark" and "Headings skip levels"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:03:22 -06:00
FrankZamora
1c901ecdf9 fix(accessibility): Fix cta-post contrast and heading hierarchy
Phase 4.4 Accessibility fixes:
- cta-post: button_text_color from #ffffff to #0E2337 (WCAG AA 4.8:1)
- TableOfContentsRenderer: h4 toc-title changed to span (semantic)
- FooterRenderer: h5 widget-title changed to span (5 instances)

Fixes: "Low contrast on cta-button" and "Headings skip levels"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:59:06 -06:00
FrankZamora
281c05fa33 fix(accessibility): Correct heading hierarchy and identical links
Phase 4.4 Accessibility fixes:
- ContactFormRenderer: Change h6 info-labels to span (WhatsApp, Email, Location)
- RelatedPostRenderer: Change h5 card-title to span (semantic hierarchy)
- top-notification-bar schema: Change link_url default from # to /suscripcion-vip
  (identical links must have same destination)

Fixes: "Headings not in sequential order" and "Identical links have different purposes"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:45:38 -06:00
FrankZamora
0a303be198 fix(accessibility): Update cta-box-sidebar button text color for WCAG AA
Changed button_text_color from #FF8600 to #0E2337 (navy-dark)
- Orange on white had only 2.9:1 contrast ratio
- Navy-dark on white provides 12.6:1 contrast ratio (WCAG AAA)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:40:58 -06:00
FrankZamora
6edb2ebeaa fix(accessibility): Update colors for WCAG AA contrast compliance
Phase 4.4 PageSpeed Accessibility fixes:
- cta-box-sidebar: title/description colors from white to navy-dark (#0E2337)
- contact-form: info_value_color from #D5D8DA to #495057, button text to navy-dark
- cta-lets-talk: text_color from white to navy-dark
- css-tablas-apu: .c3 column color from #6c757d to #495057 (7.0:1 ratio)

All changes ensure minimum 4.5:1 contrast ratio for normal text (WCAG AA).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:31:18 -06:00
FrankZamora
4ad48b4326 fix(accessibility): Improve color contrast for WCAG AA compliance
Phase 4.4 Accessibility:
- Change white text to navy-dark on orange table headers (ratio 4.8:1)
- Fix subtotal rows in APU tables: orange text to navy for better contrast
- Affects styles 2, 5, 7, 10 in generic tables
- Fixes PageSpeed accessibility warnings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:16:55 -06:00
FrankZamora
23a3c4d074 perf(pagespeed): Preload todas las fuentes + diferir CSS no críticos
Fase 4.3 optimización CLS:
- Preload Poppins 400, 500, 600, 700 (antes solo 400, 600)
- Preload bootstrap-icons font
- Diferir: bootstrap-icons, accessibility, responsive, utilities CSS

Objetivo: CLS 0.109 → ≤0.10, Performance 96 → 100

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 16:00:57 -06:00
FrankZamora
83717771c0 feat(pagespeed): Fase 4.3 - Preload fuentes para reducir CLS
- Agregar preload de Poppins regular (400) y semibold (600)
- Agregar fallback font con size-adjust para minimizar layout shift
- Actualizar font stack con 'Poppins Fallback'

Impacto esperado: CLS 0.133 → ~0.05-0.08

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 15:45:01 -06:00
FrankZamora
d7915d372b refactor(adsense): remove dead code + PageSpeed CSS defer
- Remove unused roi_should_disable_auto_ads() function
- Remove enable_page_level_ads code (Auto Ads disabled in Google panel)
- Defer non-critical CSS with media=print pattern (Fase 4.2 PageSpeed)
- Fix roi-accessibility dependency: roi-theme-style -> roi-main-style

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 15:38:49 -06:00
FrankZamora
2acce34d9e fix(adsense): Disable Auto Ads when disable_auto_ads is enabled
- Add enable_page_level_ads: false to prevent Google from
  automatically inserting ads in unwanted locations
- This prevents ads from appearing inside table cells and
  other inappropriate places

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 15:29:16 -06:00
FrankZamora
99cde7c3d6 fix(adsense): registrar filtro the_content y corregir ancho de contenedor
- Registra filtro the_content para inyectar anuncios (post-top, post-bottom, content)
- Corrige CSS del contenedor .roi-ad-slot con width:100% para evitar availableWidth=0
- Usa ContentAdInjector para insertar ads dentro del contenido

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 15:07:12 -06:00
FrankZamora
50a8c2bf18 debug: remove condition to test if enqueue works 2025-11-27 14:42:43 -06:00
FrankZamora
096f9716ef fix(youtube-facade): use is_singular() instead of is_single()
Changed condition from is_single() to is_singular() to ensure
YouTube facade assets load on all singular content types.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:41:42 -06:00
FrankZamora
2f19a7c077 Fase 4.1: Bootstrap Icons subset (94% reduccion)
Optimizacion PageSpeed:
- Original: 211 KB (2050 iconos)
- Subset: 13 KB (104 iconos usados)
- Ahorro: 198 KB (94% reduccion)

Cambios:
- Creado script create-icons-subset.py para generar subsets
- Generado bootstrap-icons-subset.min.css (4.5 KB)
- Generado bootstrap-icons-subset.woff2 (8.7 KB)
- Agregado font-display:swap (elimina bloqueo de 420ms)
- Actualizado enqueue-scripts.php para usar subset

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:34:52 -06:00
FrankZamora
cd09666f1d Backup antes de optimizar Bootstrap Icons (subset)
Estado actual:
- Bootstrap Icons completo: 211 KB (2050 iconos)
- Solo usamos 105 iconos (5.1%)

Próximo paso: crear subset de iconos para ahorrar ~199 KB

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:31:04 -06:00
FrankZamora
b43cb22dc1 feat(youtube-facade): Phase 2.4 complete - YouTube Facade for PageSpeed
- Replace YouTube iframes with lightweight facade (thumbnail + play button)
- Load real iframe only on user click
- Reduces TBT by ~2000ms
- Works with mu-plugin allow-unfiltered-html.php
- Filter priority 101 (after RCP)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:05:34 -06:00
FrankZamora
deef577c36 fix: Change filter priority to 101 (after RCP) 2025-11-27 13:58:20 -06:00
FrankZamora
d5bdb81cbe debug: Add result verification logging 2025-11-27 13:57:17 -06:00
FrankZamora
56a7c29653 debug: Add renderer output logging 2025-11-27 13:55:09 -06:00
FrankZamora
acdfeffd75 debug: Add detailed pattern matching logging 2025-11-27 13:53:56 -06:00
FrankZamora
eeacfdb284 debug: Add trace logging to functions.php try-catch block 2025-11-27 13:44:11 -06:00
FrankZamora
d867212790 Debug: Add logging to hooks registration 2025-11-27 13:41:13 -06:00
FrankZamora
98c90756f8 Debug: Add minimal logging to verify filter execution 2025-11-27 13:40:09 -06:00
FrankZamora
52e2698279 feat(youtube-facade): Phase 2.4 - YouTube Facade Pattern for PageSpeed optimization
- Replace YouTube iframes with lightweight facade (thumbnail + play button)
- Load real iframe only on user click
- Reduces TBT by ~2000ms
- Uses youtube-nocookie.com for privacy-enhanced mode
2025-11-27 13:37:36 -06:00
FrankZamora
83b594a750 Debug: Add file-based logging to bypass WP_DEBUG 2025-11-27 13:36:41 -06:00
FrankZamora
4ac03bd3e2 Debug: Add logging to YouTube Facade filter to diagnose issue 2025-11-27 13:33:45 -06:00
FrankZamora
ec8f1f0589 fix(youtube-facade): improve regex pattern for iframe detection
- Made regex more flexible to handle various attribute orders
- Added quick check before regex processing
- Added null check for preg_replace_callback result
- Supports both single and double quotes in src attribute

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 13:31:11 -06:00
FrankZamora
133b364c78 feat(pagespeed): implement YouTube Facade Pattern - Phase 2.4
PageSpeed Optimization to reduce TBT by ~2000ms:
- Add YoutubeFacade module following Clean Architecture
- Replace YouTube iframes with thumbnail + play button
- Load real iframe only on user click (lazy-load)
- Reduces initial page blocking time significantly

Files added:
- Public/YoutubeFacade/Infrastructure/Wordpress/YoutubeFacadeHooksRegistrar.php
- Public/YoutubeFacade/Infrastructure/Services/YoutubeFacadeContentFilter.php
- Public/YoutubeFacade/Infrastructure/Ui/YoutubeFacadeRenderer.php
- Public/YoutubeFacade/Infrastructure/Ui/Assets/Css/youtube-facade.css
- Public/YoutubeFacade/Infrastructure/Ui/Assets/Js/youtube-facade.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 13:28:20 -06:00
FrankZamora
b0def25348 perf(TBT): Fase 2.3 - Eliminar código JS muerto (-96%)
Diagnóstico:
- main.js: ~95% código muerto (IDs no coinciden con DOM)
- header.js: ~90% código muerto (usa Bootstrap, no custom menu)

Cambios:
- main.js: 315 → 25 líneas (solo navbar scroll effect)
- header.js: DESHABILITADO completamente (343 líneas)
- Reducción total: ~633 líneas de JS innecesario

Impacto esperado: TBT -50-100ms

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 12:59:19 -06:00
FrankZamora
7e13678e0b fix(CLS): Add hero critical CSS to HEAD to prevent layout shift
- Add roi_output_hero_critical_css() function to inject CSS in <head>
- CSS now loads BEFORE hero HTML renders, preventing CLS
- Update min_height from 120px to 260px to match actual content height
- Update title_min_height from 3rem to 3.5rem
- Add responsive breakpoints for mobile (200px min-height)

This fixes CLS in Lighthouse Lab tests (was 0.265, now should be ~0.01)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 12:08:10 -06:00
FrankZamora
7a539a498f fix: Corregir case-sensitivity en ruta de Schemas para Linux
- Cambiar '/schemas' a '/Schemas' en MigrationCommand.php
- Permite que wp roi-theme sync_component funcione en servidores Linux

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 11:58:56 -06:00
FrankZamora
90ac8a16cc perf: Agregar min-height a Hero Section para reducir CLS
- Leer campos min_height, title_min_height, badge_min_height del schema
- Aplicar min-height a .hero-section (120px)
- Aplicar min-height a .hero-section__title (3rem)
- Aplicar min-height a .hero-section__badge (32px)

Fase 1 del plan de optimización PageSpeed (99-pagespeed-optimization-plan.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 11:56:08 -06:00
FrankZamora
8f4e854a20 fix: Corregir case-sensitivity en namespaces PHP para compatibilidad Linux
- HeroSectionRenderer: namespace herosection → HeroSection (PascalCase)
- TopNotificationBarFormBuilder: namespace UI → Ui
- TopNotificationBarRenderer: @package docblock corregido
- LegacyDBManagerAdapter: ROITheme\Component → ROITheme\Shared
- LegacyDBManagerAdapter: ROITheme\Domain\Component → ROITheme\Shared\Domain\Entities
- functions-addon: actualizada referencia a HeroSectionRenderer

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 11:51:23 -06:00
FrankZamora
a062529e82 fix: Case-sensitivity en namespaces Wordpress -> WordPress
PROBLEMA:
- El modal de contacto no se mostraba en producción (Linux)
- Funcionaba en local (Windows) porque filesystem es case-insensitive
- Carpeta: `WordPress` (con P mayúscula)
- Namespaces: `Wordpress` (con p minúscula)

SOLUCION:
- Corregir todos los namespaces de `Wordpress` a `WordPress`
- También corregir paths incorrectos `ROITheme\Component\...` a `ROITheme\Shared\...`

ARCHIVOS CORREGIDOS (14):
- functions.php
- Admin/Infrastructure/Api/WordPress/AdminMenuRegistrar.php
- Admin/Shared/Infrastructure/Api/WordPress/AdminAjaxHandler.php
- Public/ContactForm/Infrastructure/Api/WordPress/ContactFormAjaxHandler.php
- Public/Footer/Infrastructure/Api/WordPress/NewsletterAjaxHandler.php
- Shared/Infrastructure/Api/WordPress/AjaxController.php
- Shared/Infrastructure/Api/WordPress/MigrationCommand.php
- Shared/Infrastructure/Di/DIContainer.php
- Shared/Infrastructure/Persistence/WordPress/WordPressComponentRepository.php
- Shared/Infrastructure/Persistence/WordPress/WordPressComponentSettingsRepository.php
- Shared/Infrastructure/Persistence/WordPress/WordPressDefaultsRepository.php
- Shared/Infrastructure/Services/CleanupService.php
- Shared/Infrastructure/Services/SchemaSyncService.php
- Shared/Infrastructure/Services/WordPressValidationService.php

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 11:11:13 -06:00
FrankZamora
7a34d1f2ae Bump ROI_VERSION to 1.0.20 for cache invalidation
Forces browsers to fetch updated adsense-loader.js with the
typo fix (roidsenseDelayed → roiAdsenseDelayed).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 09:57:35 -06:00
FrankZamora
a46126e015 Fix: typo in adsense-loader.js - roidsenseDelayed → roiAdsenseDelayed
The loader was checking for window.roidsenseDelayed but PHP sets
window.roiAdsenseDelayed, causing the loader to never initialize
and AdSense scripts to remain dormant.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 09:55:47 -06:00
FrankZamora
1876231ac1 Fix: AdSense delay regex now preserves ?client= parameter
The AdSense delay system was stripping the ?client=ca-pub-XXXXXX
parameter from script URLs during the regex replacement, causing
ads to fail loading silently.

Changed regex patterns to use capture groups ($1) to preserve the
complete URL including query parameters.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 09:47:07 -06:00
FrankZamora
c6e156089d Fix: Remove pointer-events:none CSS that blocked dropdown link clicks 2025-11-26 23:54:30 -06:00
FrankZamora
32d76c4ce8 Remove redundant JS dropdown handler - PHP fix is sufficient 2025-11-26 23:52:05 -06:00
FrankZamora
2831cabec9 Fix: ROI_Bootstrap_Nav_Walker - allow dropdown links with URLs to navigate
- Apply same fix to NavbarRenderer's walker class
- Only add data-bs-toggle=dropdown for items without real URL
- Fixes Buscador General link navigation
2025-11-26 23:49:10 -06:00
FrankZamora
4cbde7e1b7 Fix: allow dropdown parent links with URLs to navigate on click
- Only add data-bs-toggle=dropdown for items without real URL
- CSS hover handles dropdown display on desktop
- Enables Buscador General link to navigate to /buscar-apus/
2025-11-26 23:46:38 -06:00
FrankZamora
14e68031ac Fix: use capture phase for navbar dropdown click handler
Added capture: true and stopImmediatePropagation() to ensure
the click handler runs before Bootstrap's dropdown handlers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 23:44:12 -06:00
FrankZamora
1a03205aba Fix: allow navbar dropdown parent links to navigate on desktop
Added JavaScript to handle click on dropdown-toggle links:
- On desktop (>= 992px): navigates to the href URL
- On mobile: allows Bootstrap dropdown toggle to work

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 23:42:28 -06:00
FrankZamora
620ca115fb Fix: full-width layout for pages without sidebar
Added CSS rule .no-sidebar .content-wrapper { grid-template-columns: 1fr }
to make content full-width when no sidebar is present.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 23:38:21 -06:00
FrankZamora
af16230cf9 fix: corregir rutas case-sensitive para Linux
- Renombrar Assets/Vendor/Fonts → fonts (Bootstrap Icons CSS espera lowercase)
- Corregir path del preload en performance.php: Vendor/Bootstrap/Css (PascalCase)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 23:19:10 -06:00
FrankZamora
0f947f6677 fix(assets): Add Bootstrap vendor files with PascalCase paths
- Add Assets/Vendor/Bootstrap/ (CSS and JS)
- Add Assets/Vendor/Fonts/ (Bootstrap Icons fonts)
- Fix path references in enqueue-scripts.php to use PascalCase
- Remove vendor/ from .gitignore (not using Composer)

Fixes CSS 404 errors on Linux production server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 23:04:32 -06:00
FrankZamora
33d17f4b56 fix(structure): Rename assets and inc folders for Linux compatibility
- assets → Assets
- inc → Inc

Completes the case-sensitivity fixes for Linux servers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:55:31 -06:00
FrankZamora
90863cd8f5 fix(structure): Correct case-sensitivity for Linux compatibility
Rename folders to match PHP PSR-4 autoloading conventions:
- schemas → Schemas
- shared → Shared
- Wordpress → WordPress (in all locations)

Fixes deployment issues on Linux servers where filesystem is case-sensitive.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:53:34 -06:00
FrankZamora
a2548ab5c2 perf(featured-image): Optimize LCP with fetchpriority=high
- Disable lazy loading by default for featured images (LCP element)
- Add fetchpriority="high" attribute for faster loading
- Remove fetchpriority when lazy loading is enabled

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:34:11 -06:00
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
450 changed files with 113824 additions and 23053 deletions

View File

@@ -0,0 +1,17 @@
Implement the OpenSpec change: $ARGUMENTS
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
Track these steps as TODOs and complete them one by one.
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.

View File

@@ -0,0 +1,21 @@
Archive the completed OpenSpec change: $ARGUMENTS
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
1. Determine the change ID to archive:
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
**Reference**
- Use `openspec list` to confirm change IDs before archiving.
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.

View File

@@ -0,0 +1,22 @@
Create an OpenSpec change proposal for: $ARGUMENTS
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.

43
.commitlintrc.json Normal file
View File

@@ -0,0 +1,43 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert"
]
],
"scope-enum": [
2,
"always",
[
"theme",
"templates",
"assets",
"css",
"js",
"php",
"admin",
"api",
"schema",
"seo",
"config",
"deps"
]
],
"subject-case": [2, "always", "lower-case"],
"subject-max-length": [2, "always", 72],
"body-max-line-length": [2, "always", 72]
}
}

11
.gitignore vendored
View File

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

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit $1

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>

18
AGENTS.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\AdsensePlacement\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
final class AdsensePlacementFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'adsense-placement';
}
public function getFieldMapping(): array
{
return [
// VISIBILITY
'adsense-placementEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'adsense-placementShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'adsense-placementShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'adsense-placementHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// ANALYTICS (Google Analytics)
'adsense-placementAnalyticsEnabled' => ['group' => 'analytics', 'attribute' => 'analytics_enabled'],
'adsense-placementGaTrackingId' => ['group' => 'analytics', 'attribute' => 'ga_tracking_id'],
'adsense-placementGaAnonymizeIp' => ['group' => 'analytics', 'attribute' => 'ga_anonymize_ip'],
// CONTENT (Credentials)
'adsense-placementPublisherId' => ['group' => 'content', 'attribute' => 'publisher_id'],
'adsense-placementSlotDisplay' => ['group' => 'content', 'attribute' => 'slot_display'],
'adsense-placementSlotAuto' => ['group' => 'content', 'attribute' => 'slot_auto'],
'adsense-placementSlotAutorelaxed' => ['group' => 'content', 'attribute' => 'slot_autorelaxed'],
'adsense-placementSlotInarticle' => ['group' => 'content', 'attribute' => 'slot_inarticle'],
'adsense-placementSlotSkyscraper' => ['group' => 'content', 'attribute' => 'slot_skyscraper'],
// BEHAVIOR (Post locations + formats)
'adsense-placementPostTopEnabled' => ['group' => 'behavior', 'attribute' => 'post_top_enabled'],
'adsense-placementPostTopFormat' => ['group' => 'behavior', 'attribute' => 'post_top_format'],
'adsense-placementPostContentEnabled' => ['group' => 'behavior', 'attribute' => 'post_content_enabled'],
'adsense-placementPostContentRandomMode' => ['group' => 'behavior', 'attribute' => 'post_content_random_mode'],
'adsense-placementPostContentMinAds' => ['group' => 'behavior', 'attribute' => 'post_content_min_ads'],
'adsense-placementPostContentMaxAds' => ['group' => 'behavior', 'attribute' => 'post_content_max_ads'],
'adsense-placementPostContentAfterParagraphs' => ['group' => 'behavior', 'attribute' => 'post_content_after_paragraphs'],
'adsense-placementPostContentMinParagraphsBetween' => ['group' => 'behavior', 'attribute' => 'post_content_min_paragraphs_between'],
'adsense-placementPostContentFormat' => ['group' => 'behavior', 'attribute' => 'post_content_format'],
'adsense-placementPostBottomEnabled' => ['group' => 'behavior', 'attribute' => 'post_bottom_enabled'],
'adsense-placementPostBottomFormat' => ['group' => 'behavior', 'attribute' => 'post_bottom_format'],
'adsense-placementAfterRelatedEnabled' => ['group' => 'behavior', 'attribute' => 'after_related_enabled'],
'adsense-placementAfterRelatedFormat' => ['group' => 'behavior', 'attribute' => 'after_related_format'],
// BEHAVIOR (Rail Ads)
'adsense-placementRailAdsEnabled' => ['group' => 'behavior', 'attribute' => 'rail_ads_enabled'],
'adsense-placementRailLeftEnabled' => ['group' => 'behavior', 'attribute' => 'rail_left_enabled'],
'adsense-placementRailRightEnabled' => ['group' => 'behavior', 'attribute' => 'rail_right_enabled'],
'adsense-placementRailFormat' => ['group' => 'behavior', 'attribute' => 'rail_format'],
'adsense-placementRailTopOffset' => ['group' => 'behavior', 'attribute' => 'rail_top_offset'],
// LAYOUT (Archive/Global locations + formats)
'adsense-placementArchiveTopEnabled' => ['group' => 'layout', 'attribute' => 'archive_top_enabled'],
'adsense-placementArchiveBetweenEnabled' => ['group' => 'layout', 'attribute' => 'archive_between_enabled'],
'adsense-placementArchiveBetweenEvery' => ['group' => 'layout', 'attribute' => 'archive_between_every'],
'adsense-placementArchiveBottomEnabled' => ['group' => 'layout', 'attribute' => 'archive_bottom_enabled'],
'adsense-placementArchiveFormat' => ['group' => 'layout', 'attribute' => 'archive_format'],
'adsense-placementHeaderBelowEnabled' => ['group' => 'layout', 'attribute' => 'header_below_enabled'],
'adsense-placementFooterAboveEnabled' => ['group' => 'layout', 'attribute' => 'footer_above_enabled'],
'adsense-placementGlobalFormat' => ['group' => 'layout', 'attribute' => 'global_format'],
// FORMS (Exclusions + Delay)
'adsense-placementExcludeCategories' => ['group' => 'forms', 'attribute' => 'exclude_categories'],
'adsense-placementExcludePostTypes' => ['group' => 'forms', 'attribute' => 'exclude_post_types'],
'adsense-placementExcludePostIds' => ['group' => 'forms', 'attribute' => 'exclude_post_ids'],
'adsense-placementMinContentLength' => ['group' => 'forms', 'attribute' => 'min_content_length'],
'adsense-placementDelayEnabled' => ['group' => 'forms', 'attribute' => 'delay_enabled'],
'adsense-placementDelayTimeout' => ['group' => 'forms', 'attribute' => 'delay_timeout'],
// ANCHOR ADS
'adsense-placementAnchorEnabled' => ['group' => 'anchor_ads', 'attribute' => 'anchor_enabled'],
'adsense-placementAnchorPosition' => ['group' => 'anchor_ads', 'attribute' => 'anchor_position'],
'adsense-placementAnchorHeight' => ['group' => 'anchor_ads', 'attribute' => 'anchor_height'],
'adsense-placementAnchorCollapsibleEnabled' => ['group' => 'anchor_ads', 'attribute' => 'anchor_collapsible_enabled'],
'adsense-placementAnchorShowOnMobile' => ['group' => 'anchor_ads', 'attribute' => 'anchor_show_on_mobile'],
'adsense-placementAnchorShowOnWideScreens' => ['group' => 'anchor_ads', 'attribute' => 'anchor_show_on_wide_screens'],
'adsense-placementAnchorRememberState' => ['group' => 'anchor_ads', 'attribute' => 'anchor_remember_state'],
'adsense-placementAnchorRememberDuration' => ['group' => 'anchor_ads', 'attribute' => 'anchor_remember_duration'],
// VIGNETTE ADS
'adsense-placementVignetteEnabled' => ['group' => 'vignette_ads', 'attribute' => 'vignette_enabled'],
'adsense-placementVignetteTrigger' => ['group' => 'vignette_ads', 'attribute' => 'vignette_trigger'],
'adsense-placementVignetteTriggerDelay' => ['group' => 'vignette_ads', 'attribute' => 'vignette_trigger_delay'],
'adsense-placementVignetteSize' => ['group' => 'vignette_ads', 'attribute' => 'vignette_size'],
'adsense-placementVignetteOverlayOpacity' => ['group' => 'vignette_ads', 'attribute' => 'vignette_overlay_opacity'],
'adsense-placementVignetteShowOnMobile' => ['group' => 'vignette_ads', 'attribute' => 'vignette_show_on_mobile'],
'adsense-placementVignetteShowOnDesktop' => ['group' => 'vignette_ads', 'attribute' => 'vignette_show_on_desktop'],
'adsense-placementVignetteReshowEnabled' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_enabled'],
'adsense-placementVignetteReshowTime' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_time'],
'adsense-placementVignetteMaxPerSession' => ['group' => 'vignette_ads', 'attribute' => 'vignette_max_per_session'],
// SEARCH RESULTS (ROI APU Search)
'adsense-placementSearchAdsEnabled' => ['group' => 'search_results', 'attribute' => 'search_ads_enabled'],
'adsense-placementSearchTopAdEnabled' => ['group' => 'search_results', 'attribute' => 'search_top_ad_enabled'],
'adsense-placementSearchTopAdFormat' => ['group' => 'search_results', 'attribute' => 'search_top_ad_format'],
'adsense-placementSearchBetweenEnabled' => ['group' => 'search_results', 'attribute' => 'search_between_enabled'],
'adsense-placementSearchBetweenMax' => ['group' => 'search_results', 'attribute' => 'search_between_max'],
'adsense-placementSearchBetweenFormat' => ['group' => 'search_results', 'attribute' => 'search_between_format'],
'adsense-placementSearchBetweenPosition' => ['group' => 'search_results', 'attribute' => 'search_between_position'],
'adsense-placementSearchBetweenEvery' => ['group' => 'search_results', 'attribute' => 'search_between_every'],
// Page Visibility (grupo especial _page_visibility)
'adsense-placementVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'adsense-placementVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'adsense-placementVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'adsense-placementVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'adsense-placementVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'adsense-placementExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'adsense-placementExcludeCategoriesAdv' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'adsense-placementExcludePostIdsAdv' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'adsense-placementExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
];
}
}

View File

@@ -0,0 +1,985 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para AdSense Placement y Google Analytics
*
* Panel reorganizado con:
* - Diagrama visual de ubicaciones
* - Secciones colapsables
* - In-content ads configurables (1-8 random)
*/
final class AdsensePlacementFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
// HEADER CON GRADIENTE
$html .= '<div class="rounded p-4 mb-4 shadow text-white" 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 .= ' AdSense y Analytics';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Configura Google AdSense y Analytics con ubicaciones visuales';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
// LAYOUT 2 COLUMNAS
$html .= '<div class="row g-3">';
// COLUMNA IZQUIERDA (7 cols)
$html .= ' <div class="col-lg-7">';
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildDiagramSection();
$html .= $this->buildPostLocationsGroup($componentId);
$html .= $this->buildInContentAdsGroup($componentId);
$html .= $this->buildExclusionsGroup($componentId);
$html .= ' </div>';
// COLUMNA DERECHA (5 cols)
$html .= ' <div class="col-lg-5">';
$html .= $this->buildCredentialsGroup($componentId);
$html .= $this->buildAnalyticsGroup($componentId);
$html .= $this->buildRailAdsGroup($componentId);
$html .= $this->buildAnchorAdsGroup($componentId);
$html .= $this->buildVignetteAdsGroup($componentId);
$html .= $this->buildSearchResultsGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #28a745;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-power me-2" style="color: #28a745;"></i>';
$html .= ' Activacion Global';
$html .= ' </h5>';
$html .= '<div class="row g-3">';
$html .= ' <div class="col-md-4">';
$enabled = $this->renderer->getFieldValue($cid, 'visibility', 'is_enabled', false);
$html .= $this->buildSwitch($cid . 'Enabled', 'Activar AdSense', $enabled, 'bi-power');
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$showMobile = $this->renderer->getFieldValue($cid, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch($cid . 'ShowOnMobile', 'Mostrar en movil', $showMobile, 'bi-phone');
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$showDesktop = $this->renderer->getFieldValue($cid, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch($cid . 'ShowOnDesktop', 'Mostrar en escritorio', $showDesktop, 'bi-display');
$html .= ' </div>';
$html .= '</div>';
// Opcion para ocultar anuncios a usuarios logueados
$html .= '<div class="mt-3 p-2 rounded" style="background: #fff3cd;">';
$hideForLoggedIn = $this->renderer->getFieldValue($cid, 'visibility', 'hide_for_logged_in', false);
$html .= $this->buildSwitch($cid . 'HideForLoggedIn', 'Ocultar para usuarios logueados', $hideForLoggedIn, 'bi-person-lock');
$html .= '<small class="text-muted d-block" style="margin-top: -8px; margin-left: 40px;">No mostrar anuncios a usuarios con sesion iniciada en WordPress</small>';
$html .= '</div>';
// =============================================
// Visibilidad por tipo de pagina
// Grupo especial: _page_visibility (Plan 99.11)
// =============================================
$html .= '<hr class="my-3">';
$html .= '<p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-layout-text-window me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= '</p>';
$showOnHome = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_search', true);
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= '</div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($cid, $cid);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Diagrama visual de ubicaciones de anuncios
*/
private function buildDiagramSection(): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #6f42c1;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-layout-text-window-reverse me-2" style="color: #6f42c1;"></i>';
$html .= ' Mapa de Ubicaciones';
$html .= ' </h5>';
// Diagrama visual del layout
$html .= '<div class="border rounded p-3" style="background: #f8f9fa; font-family: monospace; font-size: 11px;">';
// Anchor Top
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #d1ecf1; border: 2px solid #17a2b8;">';
$html .= ' <i class="bi bi-pin-angle"></i> <strong>ANCHOR TOP</strong> (fijo, collapsible)';
$html .= '</div>';
// Header
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #e9ecef; border: 1px dashed #6c757d;">';
$html .= ' <strong>HEADER</strong>';
$html .= '</div>';
// Hero / Featured Image
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #d1e7dd; border: 1px solid #198754;">';
$html .= ' <i class="bi bi-image"></i> Featured Image / Hero';
$html .= '</div>';
// Ad: Post Top
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #fff3cd; border: 2px solid #ffc107;">';
$html .= ' <i class="bi bi-megaphone"></i> <strong>📍 POST-TOP</strong> (Despues de imagen)';
$html .= '</div>';
// Content container
$html .= '<div class="p-2 mb-1 rounded" style="background: #fff; border: 1px solid #dee2e6;">';
$html .= ' <div class="mb-1 small text-muted text-center">📝 CONTENIDO DEL POST</div>';
$html .= ' <div class="p-1 rounded mb-1" style="background: #e7f1ff; font-size: 10px;">Parrafo 1...</div>';
$html .= ' <div class="p-1 rounded mb-1" style="background: #e7f1ff; font-size: 10px;">Parrafo 2...</div>';
$html .= ' <div class="p-1 rounded mb-1" style="background: #e7f1ff; font-size: 10px;">Parrafo 3...</div>';
// In-content ad
$html .= ' <div class="text-center p-1 mb-1 rounded" style="background: #fff3cd; border: 2px dashed #ffc107; font-size: 10px;">';
$html .= ' <i class="bi bi-megaphone"></i> <strong>📍 IN-CONTENT #1</strong>';
$html .= ' </div>';
$html .= ' <div class="p-1 rounded mb-1" style="background: #e7f1ff; font-size: 10px;">Parrafo 4...</div>';
$html .= ' <div class="p-1 rounded mb-1" style="background: #e7f1ff; font-size: 10px;">Parrafo 5...</div>';
$html .= ' <div class="p-1 rounded mb-1" style="background: #e7f1ff; font-size: 10px;">Parrafo 6...</div>';
// In-content ad 2
$html .= ' <div class="text-center p-1 mb-1 rounded" style="background: #fff3cd; border: 2px dashed #ffc107; font-size: 10px;">';
$html .= ' <i class="bi bi-megaphone"></i> <strong>📍 IN-CONTENT #2</strong> (random)';
$html .= ' </div>';
$html .= ' <div class="p-1 rounded" style="background: #e7f1ff; font-size: 10px;">Mas parrafos...</div>';
$html .= '</div>';
// Ad: Post Bottom
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #fff3cd; border: 2px solid #ffc107;">';
$html .= ' <i class="bi bi-megaphone"></i> <strong>📍 POST-BOTTOM</strong> (Despues del contenido)';
$html .= '</div>';
// Related Posts
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #cfe2ff; border: 1px solid #0d6efd;">';
$html .= ' <i class="bi bi-grid-3x2"></i> Related Posts';
$html .= '</div>';
// Ad: After Related
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #fff3cd; border: 2px solid #ffc107;">';
$html .= ' <i class="bi bi-megaphone"></i> <strong>📍 AFTER-RELATED</strong>';
$html .= '</div>';
// Footer
$html .= '<div class="text-center p-2 mb-1 rounded" style="background: #e9ecef; border: 1px dashed #6c757d;">';
$html .= ' <strong>FOOTER</strong>';
$html .= '</div>';
// Anchor Bottom
$html .= '<div class="text-center p-2 rounded" style="background: #d1ecf1; border: 2px solid #17a2b8;">';
$html .= ' <i class="bi bi-pin-angle"></i> <strong>ANCHOR BOTTOM</strong> (fijo, collapsible)';
$html .= '</div>';
// Rail Ads (laterales)
$html .= '<div class="mt-2 d-flex justify-content-between">';
$html .= ' <div class="p-2 rounded text-center" style="width: 45%; background: #f8d7da; border: 2px solid #dc3545; font-size: 10px;">';
$html .= ' <strong>📍 RAIL IZQ</strong><br><small>(160x600)</small>';
$html .= ' </div>';
$html .= ' <div class="p-2 rounded text-center" style="width: 45%; background: #f8d7da; border: 2px solid #dc3545; font-size: 10px;">';
$html .= ' <strong>📍 RAIL DER</strong><br><small>(160x600)</small>';
$html .= ' </div>';
$html .= '</div>';
// Vignette Ad (modal)
$html .= '<div class="mt-2 p-2 rounded text-center" style="background: #f3e5f5; border: 2px solid #9c27b0;">';
$html .= ' <i class="bi bi-fullscreen"></i> <strong>VIGNETTE</strong> (modal pantalla completa)';
$html .= ' <br><small class="text-muted">Aparece segun trigger configurado</small>';
$html .= '</div>';
$html .= '</div>';
$html .= '<div class="mt-2 small text-muted">';
$html .= ' <i class="bi bi-info-circle"></i> <span class="badge bg-warning text-dark">Amarillo</span> = Posts, ';
$html .= ' <span class="badge bg-danger">Rojo</span> = Rails &gt;1600px, ';
$html .= ' <span class="badge" style="background:#17a2b8;color:white;">Cyan</span> = Anchors, ';
$html .= ' <span class="badge" style="background:#9c27b0;color:white;">Morado</span> = Vignette';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildPostLocationsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #ffc107;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-geo-alt me-2" style="color: #ffc107;"></i>';
$html .= ' Ubicaciones en Posts';
$html .= ' </h5>';
// === POST-TOP ===
$html .= '<div class="border rounded p-3 mb-3" style="background: #fffbeb;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge bg-warning text-dark">POST-TOP</span>';
$html .= ' <small class="text-muted">Despues de la imagen destacada</small>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$postTopEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'post_top_enabled', true);
$html .= $this->buildSwitch($cid . 'PostTopEnabled', 'Activar', $postTopEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$html .= $this->buildSelect($cid . 'PostTopFormat', 'Formato',
$this->renderer->getFieldValue($cid, 'behavior', 'post_top_format', 'auto'),
[
'auto' => 'Auto (responsive)',
'in-article' => 'In-Article (fluid)',
'display' => 'Display (728x90)',
'display-large' => 'Display Large (970x250)'
]
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
// === POST-BOTTOM ===
$html .= '<div class="border rounded p-3 mb-3" style="background: #fffbeb;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge bg-warning text-dark">POST-BOTTOM</span>';
$html .= ' <small class="text-muted">Despues del contenido, antes de Related</small>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$postBottomEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'post_bottom_enabled', true);
$html .= $this->buildSwitch($cid . 'PostBottomEnabled', 'Activar', $postBottomEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$html .= $this->buildSelect($cid . 'PostBottomFormat', 'Formato',
$this->renderer->getFieldValue($cid, 'behavior', 'post_bottom_format', 'auto'),
['auto' => 'Auto', 'in-article' => 'In-Article', 'display' => 'Display']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
// === AFTER-RELATED ===
$html .= '<div class="border rounded p-3" style="background: #fffbeb;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge bg-warning text-dark">AFTER-RELATED</span>';
$html .= ' <small class="text-muted">Despues de Related Posts</small>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$afterRelatedEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'after_related_enabled', false);
$html .= $this->buildSwitch($cid . 'AfterRelatedEnabled', 'Activar', $afterRelatedEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$html .= $this->buildSelect($cid . 'AfterRelatedFormat', 'Formato',
$this->renderer->getFieldValue($cid, 'behavior', 'after_related_format', 'autorelaxed'),
['autorelaxed' => 'Autorelaxed (feed)', 'auto' => 'Auto']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Seccion especial para in-content ads con configuracion de 1-8 random
*/
private function buildInContentAdsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #0d6efd;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-body-text me-2" style="color: #0d6efd;"></i>';
$html .= ' Anuncios Dentro del Contenido';
$html .= ' <span class="badge bg-primary ms-2">1-8 ads</span>';
$html .= ' </h5>';
$html .= '<div class="alert alert-info small mb-3">';
$html .= ' <i class="bi bi-lightbulb me-1"></i>';
$html .= ' <strong>Modo Random:</strong> Inserta entre 1 y 8 anuncios en posiciones aleatorias entre parrafos.';
$html .= ' Mejor UX al variar la posicion en cada visita.';
$html .= '</div>';
// Master switch
$postContentEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_enabled', false);
$html .= '<div class="mb-3">';
$html .= $this->buildSwitch($cid . 'PostContentEnabled', 'Activar In-Content Ads', $postContentEnabled, 'bi-power');
$html .= '</div>';
// Configuracion de cantidad
$html .= '<div class="row g-2 mb-3">';
$html .= ' <div class="col-md-6">';
$minAdsValue = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_min_ads', '1');
$html .= $this->buildSelect($cid . 'PostContentMinAds', 'Minimo de anuncios',
is_string($minAdsValue) ? $minAdsValue : '1',
['1' => '1 anuncio', '2' => '2 anuncios', '3' => '3 anuncios', '4' => '4 anuncios']
);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$maxAdsValue = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_max_ads', '3');
$html .= $this->buildSelect($cid . 'PostContentMaxAds', 'Maximo de anuncios',
is_string($maxAdsValue) ? $maxAdsValue : '3',
[
'1' => '1 anuncio', '2' => '2 anuncios', '3' => '3 anuncios', '4' => '4 anuncios',
'5' => '5 anuncios', '6' => '6 anuncios', '7' => '7 anuncios', '8' => '8 anuncios'
]
);
$html .= ' </div>';
$html .= '</div>';
// Configuracion de posicionamiento
$html .= '<div class="row g-2 mb-3">';
$html .= ' <div class="col-md-6">';
$afterPara = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_after_paragraphs', '3');
$html .= $this->buildTextInput($cid . 'PostContentAfterParagraphs', 'Primer ad despues del parrafo #', (string)$afterPara, '3');
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$minBetweenValue = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_min_paragraphs_between', '4');
$html .= $this->buildSelect($cid . 'PostContentMinParagraphsBetween', 'Parrafos entre ads',
is_string($minBetweenValue) ? $minBetweenValue : '4',
['2' => '2 parrafos', '3' => '3 parrafos', '4' => '4 parrafos', '5' => '5 parrafos', '6' => '6 parrafos']
);
$html .= ' </div>';
$html .= '</div>';
// Modo y formato
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$randomMode = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_random_mode', true);
$html .= $this->buildSwitch($cid . 'PostContentRandomMode', 'Posiciones aleatorias', $randomMode, 'bi-shuffle');
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$formatValue = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_format', 'in-article');
$html .= $this->buildSelect($cid . 'PostContentFormat', 'Formato de ads',
is_string($formatValue) ? $formatValue : 'in-article',
['in-article' => 'In-Article (fluid)', 'auto' => 'Auto (responsive)']
);
$html .= ' </div>';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildCredentialsGroup(string $cid): 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-key me-2" style="color: #FF8600;"></i>';
$html .= ' Credenciales AdSense';
$html .= ' </h5>';
// Publisher ID
$pubId = $this->renderer->getFieldValue($cid, 'content', 'publisher_id', 'ca-pub-8476420265998726');
$html .= $this->buildTextInput($cid . 'PublisherId', 'Publisher ID', $pubId, 'ca-pub-XXXXX');
$html .= '<hr class="my-3">';
$html .= '<p class="small text-muted mb-2"><i class="bi bi-info-circle me-1"></i> Slots por tipo de anuncio:</p>';
// Slots con descripciones claras
$html .= '<div class="mb-2">';
$slotAuto = $this->renderer->getFieldValue($cid, 'content', 'slot_auto', '8471732096');
$html .= $this->buildTextInput($cid . 'SlotAuto', '📱 Auto (responsive)', $slotAuto);
$html .= '<div class="form-text small" style="margin-top:-10px;">Para: Post-Top, Post-Bottom, globales</div>';
$html .= '</div>';
$html .= '<div class="mb-2">';
$slotInArticle = $this->renderer->getFieldValue($cid, 'content', 'slot_inarticle', '7285187368');
$html .= $this->buildTextInput($cid . 'SlotInarticle', '📝 In-Article (fluid)', $slotInArticle);
$html .= '<div class="form-text small" style="margin-top:-10px;">Para: In-Content (dentro del texto)</div>';
$html .= '</div>';
$html .= '<div class="mb-2">';
$slotDisplay = $this->renderer->getFieldValue($cid, 'content', 'slot_display', '2873062302');
$html .= $this->buildTextInput($cid . 'SlotDisplay', '🖥️ Display (fijo)', $slotDisplay);
$html .= '<div class="form-text small" style="margin-top:-10px;">Para: 728x90, 970x250 (opcional)</div>';
$html .= '</div>';
$html .= '<div class="mb-2">';
$slotRelaxed = $this->renderer->getFieldValue($cid, 'content', 'slot_autorelaxed', '9205569855');
$html .= $this->buildTextInput($cid . 'SlotAutorelaxed', '📋 Autorelaxed (feed)', $slotRelaxed);
$html .= '<div class="form-text small" style="margin-top:-10px;">Para: After-Related, archives</div>';
$html .= '</div>';
$html .= '<div class="mb-2">';
$slotSkyscraper = $this->renderer->getFieldValue($cid, 'content', 'slot_skyscraper', '');
$html .= $this->buildTextInput($cid . 'SlotSkyscraper', '🏢 Skyscraper (tall)', $slotSkyscraper);
$html .= '<div class="form-text small" style="margin-top:-10px;">Para: Rail Ads laterales (160x600)</div>';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildAnalyticsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #4285f4;">';
$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: #4285f4;"></i>';
$html .= ' Google Analytics';
$html .= ' </h5>';
// Switch: Analytics Enabled
$analyticsEnabled = $this->renderer->getFieldValue($cid, 'analytics', 'analytics_enabled', false);
$html .= $this->buildSwitch($cid . 'AnalyticsEnabled', 'Activar Analytics', $analyticsEnabled, 'bi-power');
// Tracking ID
$gaTrackingId = $this->renderer->getFieldValue($cid, 'analytics', 'ga_tracking_id', '');
$html .= $this->buildTextInput($cid . 'GaTrackingId', 'Google Analytics ID', $gaTrackingId, 'G-XXXXXXXXXX');
$html .= '<div class="form-text small mb-2">Formato: G-XXXXXXXXXX (GA4) o UA-XXXXXXXX-X</div>';
// Anonymize IP
$gaAnonymizeIp = $this->renderer->getFieldValue($cid, 'analytics', 'ga_anonymize_ip', true);
$html .= $this->buildSwitch($cid . 'GaAnonymizeIp', 'Anonimizar IP (GDPR)', $gaAnonymizeIp, 'bi-shield-check');
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildRailAdsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #dc3545;">';
$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: #dc3545;"></i>';
$html .= ' Rail Ads (Laterales)';
$html .= ' <span class="badge bg-secondary ms-2">&gt;1600px</span>';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">Anuncios fijos en los margenes del viewport. Solo en pantallas muy anchas.</p>';
// Master switch
$railEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'rail_ads_enabled', false);
$html .= $this->buildSwitch($cid . 'RailAdsEnabled', 'Activar Rail Ads', $railEnabled, 'bi-power');
// Left/Right toggles
$html .= '<div class="row g-2 mt-2">';
$html .= ' <div class="col-6">';
$leftEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'rail_left_enabled', true);
$html .= $this->buildSwitch($cid . 'RailLeftEnabled', 'Rail izquierdo', $leftEnabled);
$html .= ' </div>';
$html .= ' <div class="col-6">';
$rightEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'rail_right_enabled', true);
$html .= $this->buildSwitch($cid . 'RailRightEnabled', 'Rail derecho', $rightEnabled);
$html .= ' </div>';
$html .= '</div>';
// Format select - Solo altura (el ancho es responsive)
$railFormat = $this->renderer->getFieldValue($cid, 'behavior', 'rail_format', 'h600');
$html .= $this->buildSelect($cid . 'RailFormat', 'Altura del Rail',
$railFormat,
[
'h250' => '250px (Compacto)',
'h300' => '300px (Pequeno)',
'h400' => '400px (Mediano)',
'h500' => '500px',
'h600' => '600px (Recomendado)',
'h700' => '700px',
'h800' => '800px (Grande)',
'h1050' => '1050px (Extra grande)'
]
);
$html .= '<small class="text-muted d-block mt-1 mb-2">El ancho se ajusta automaticamente al espacio disponible.</small>';
// Top offset - Select con opciones predefinidas
$topOffset = $this->renderer->getFieldValue($cid, 'behavior', 'rail_top_offset', '300');
$html .= $this->buildSelect($cid . 'RailTopOffset', 'Distancia desde arriba',
$topOffset,
[
'150' => '150px (Cerca del header)',
'200' => '200px',
'300' => '300px (Recomendado)',
'400' => '400px',
'500' => '500px',
'700' => '700px (Debajo del fold)'
]
);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Seccion para Anchor Ads (anuncios fijos top/bottom)
*/
private function buildAnchorAdsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #17a2b8;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-pin-angle me-2" style="color: #17a2b8;"></i>';
$html .= ' Anuncios Fijos (Anchor)';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">Anuncios fijos en el borde superior o inferior de la pantalla.</p>';
// Master switch
$anchorEnabled = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_enabled', false);
$html .= $this->buildSwitch($cid . 'AnchorEnabled', 'Activar Anchor Ads', $anchorEnabled, 'bi-power');
// Posicion
$anchorPosition = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_position', 'bottom');
$html .= $this->buildSelect($cid . 'AnchorPosition', 'Posicion del anuncio',
$anchorPosition,
[
'top' => 'Solo en la parte superior',
'bottom' => 'Solo en la parte inferior',
'both' => 'Superior e inferior'
]
);
// Altura
$anchorHeight = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_height', '90');
$html .= $this->buildSelect($cid . 'AnchorHeight', 'Altura del anchor',
$anchorHeight,
['50' => '50px', '90' => '90px', '100' => '100px', '120' => '120px']
);
// Collapsible toggle
$collapsible = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_collapsible_enabled', true);
$html .= $this->buildSwitch($cid . 'AnchorCollapsibleEnabled', 'Permitir minimizar', $collapsible, 'bi-arrows-collapse');
$html .= '<small class="text-muted d-block" style="margin-top: -8px; margin-left: 40px;">Usuario puede minimizar en lugar de cerrar</small>';
// Pantallas
$html .= '<div class="row g-2 mt-2">';
$html .= ' <div class="col-6">';
$showMobile = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_show_on_mobile', true);
$html .= $this->buildSwitch($cid . 'AnchorShowOnMobile', 'Mostrar en movil', $showMobile, 'bi-phone');
$html .= ' </div>';
$html .= ' <div class="col-6">';
$showWide = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_show_on_wide_screens', false);
$html .= $this->buildSwitch($cid . 'AnchorShowOnWideScreens', 'Pantallas anchas', $showWide, 'bi-display');
$html .= ' </div>';
$html .= '</div>';
// Recordar estado
$html .= '<div class="mt-3 p-2 rounded" style="background: #e7f1ff;">';
$rememberState = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_remember_state', true);
$html .= $this->buildSwitch($cid . 'AnchorRememberState', 'Recordar cierre/colapso', $rememberState, 'bi-clock-history');
$rememberDuration = $this->renderer->getFieldValue($cid, 'anchor_ads', 'anchor_remember_duration', 'session');
$html .= $this->buildSelect($cid . 'AnchorRememberDuration', 'Duracion',
$rememberDuration,
[
'session' => 'Solo esta sesion',
'1hour' => '1 hora',
'1day' => '1 dia',
'1week' => '1 semana'
]
);
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Seccion para Vignette Ads (pantalla completa)
*/
private function buildVignetteAdsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #9c27b0;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-fullscreen me-2" style="color: #9c27b0;"></i>';
$html .= ' Anuncios de Vineta';
$html .= ' <span class="badge bg-secondary ms-2">Pantalla Completa</span>';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">Anuncios que ocupan toda la pantalla, aparecen segun el trigger configurado.</p>';
// Master switch
$vignetteEnabled = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_enabled', false);
$html .= $this->buildSwitch($cid . 'VignetteEnabled', 'Activar Vignette Ads', $vignetteEnabled, 'bi-power');
// Trigger
$vignetteTrigger = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_trigger', 'pageview');
$html .= $this->buildSelect($cid . 'VignetteTrigger', 'Cuando mostrar',
(string)$vignetteTrigger,
[
'pageview' => 'Al cargar la pagina',
'scroll_50' => 'Al scrollear 50%',
'scroll_75' => 'Al scrollear 75%',
'exit_intent' => 'Al intentar salir',
'time_delay' => 'Despues de X segundos'
]
);
// Delay inicial
$triggerDelay = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_trigger_delay', '5');
$html .= $this->buildTextInput($cid . 'VignetteTriggerDelay', 'Delay inicial (segundos)', (string)$triggerDelay, '5');
// Tamano y opacidad
$html .= '<div class="row g-2 mt-2">';
$html .= ' <div class="col-6">';
$size = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_size', 'auto');
$html .= $this->buildSelect($cid . 'VignetteSize', 'Tamano',
(string)$size,
[
'auto' => 'Auto (recomendado)',
'responsive' => 'Responsive (fluid)',
'1280x720' => '1280x720 (HD 720p)',
'960x540' => '960x540 (qHD)',
'854x480' => '854x480 (480p)',
'800x450' => '800x450 (16:9)',
'640x360' => '640x360 (360p)',
'560x315' => '560x315 (YouTube)',
'300x250' => '300x250 (Rectangle)',
'336x280' => '336x280 (Large Rectangle)',
]
);
$html .= ' </div>';
$html .= ' <div class="col-6">';
$opacity = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_overlay_opacity', '0.7');
$html .= $this->buildSelect($cid . 'VignetteOverlayOpacity', 'Opacidad fondo',
(string)$opacity,
['0.5' => '50%', '0.6' => '60%', '0.7' => '70%', '0.8' => '80%', '0.9' => '90%']
);
$html .= ' </div>';
$html .= '</div>';
// Pantallas
$html .= '<div class="row g-2 mt-2">';
$html .= ' <div class="col-6">';
$showMobile = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_show_on_mobile', true);
$html .= $this->buildSwitch($cid . 'VignetteShowOnMobile', 'Mostrar en movil', $showMobile, 'bi-phone');
$html .= ' </div>';
$html .= ' <div class="col-6">';
$showDesktop = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_show_on_desktop', true);
$html .= $this->buildSwitch($cid . 'VignetteShowOnDesktop', 'Mostrar en desktop', $showDesktop, 'bi-display');
$html .= ' </div>';
$html .= '</div>';
// Reaparicion
$html .= '<div class="mt-3 p-2 rounded" style="background: #f3e5f5;">';
$html .= '<p class="small fw-semibold mb-2"><i class="bi bi-arrow-repeat me-1"></i> Reaparicion</p>';
$reshowEnabled = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_reshow_enabled', true);
$html .= $this->buildSwitch($cid . 'VignetteReshowEnabled', 'Permitir reaparicion', $reshowEnabled);
$html .= '<div class="row g-2">';
$html .= ' <div class="col-6">';
$reshowTime = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_reshow_time', '5');
$html .= $this->buildSelect($cid . 'VignetteReshowTime', 'Tiempo (min)',
(string)$reshowTime,
['1' => '1 min', '2' => '2 min', '3' => '3 min', '4' => '4 min', '5' => '5 min', '10' => '10 min', '15' => '15 min', '30' => '30 min']
);
$html .= ' </div>';
$html .= ' <div class="col-6">';
$maxSession = $this->renderer->getFieldValue($cid, 'vignette_ads', 'vignette_max_per_session', '3');
$html .= $this->buildSelect($cid . 'VignetteMaxPerSession', 'Max/sesion',
(string)$maxSession,
['1' => '1', '2' => '2', '3' => '3', '5' => '5', 'unlimited' => 'Sin limite']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Seccion para anuncios en resultados de busqueda (ROI APU Search)
*/
private function buildSearchResultsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #fd7e14;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-search me-2" style="color: #fd7e14;"></i>';
$html .= ' Resultados de Busqueda';
$html .= ' <span class="badge bg-secondary ms-2">ROI APU Search</span>';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">Insertar anuncios en los resultados del buscador de Analisis de Precios Unitarios.</p>';
// Master switch
$searchAdsEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_ads_enabled', false);
$html .= $this->buildSwitch($cid . 'SearchAdsEnabled', 'Activar ads en busqueda', $searchAdsEnabled, 'bi-power');
// Anuncio superior
$html .= '<div class="border rounded p-3 mb-3" style="background: #fff8f0;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge" style="background: #fd7e14;">ANUNCIO SUPERIOR</span>';
$html .= ' <small class="text-muted">Debajo del campo de busqueda</small>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$topEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_top_ad_enabled', true);
$html .= $this->buildSwitch($cid . 'SearchTopAdEnabled', 'Activar', $topEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$topFormat = $this->renderer->getFieldValue($cid, 'search_results', 'search_top_ad_format', 'auto');
$html .= $this->buildSelect($cid . 'SearchTopAdFormat', 'Formato',
(string)$topFormat,
['auto' => 'Auto (responsive)', 'display' => 'Display (fijo)', 'in-article' => 'In-Article (fluid)']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
// Anuncios entre resultados
$html .= '<div class="border rounded p-3" style="background: #fff8f0;">';
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
$html .= ' <span class="badge" style="background: #fd7e14;">ENTRE RESULTADOS</span>';
$html .= ' <small class="text-muted">Intercalados con los resultados</small>';
$html .= '</div>';
$html .= '<div class="row g-2 mb-2">';
$html .= ' <div class="col-md-6">';
$betweenEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_enabled', true);
$html .= $this->buildSwitch($cid . 'SearchBetweenEnabled', 'Activar', $betweenEnabled);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$betweenMax = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_max', '1');
$html .= $this->buildSelect($cid . 'SearchBetweenMax', 'Maximo ads',
(string)$betweenMax,
['1' => '1 anuncio', '2' => '2 anuncios', '3' => '3 anuncios (max)']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '<div class="row g-2 mb-2">';
$html .= ' <div class="col-md-6">';
$betweenFormat = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_format', 'in-article');
$html .= $this->buildSelect($cid . 'SearchBetweenFormat', 'Formato',
(string)$betweenFormat,
['in-article' => 'In-Article (fluid)', 'auto' => 'Auto (responsive)', 'autorelaxed' => 'Autorelaxed (feed)']
);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$betweenPosition = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_position', 'random');
$html .= $this->buildSelect($cid . 'SearchBetweenPosition', 'Posicion',
(string)$betweenPosition,
['random' => 'Aleatorio', 'fixed' => 'Fijo (cada N)', 'first_half' => 'Primera mitad']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$betweenEvery = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_every', '5');
$html .= $this->buildSelect($cid . 'SearchBetweenEvery', 'Cada N resultados (si es fijo)',
(string)$betweenEvery,
['3' => 'Cada 3', '4' => 'Cada 4', '5' => 'Cada 5', '6' => 'Cada 6', '7' => 'Cada 7', '8' => 'Cada 8', '10' => 'Cada 10']
);
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildExclusionsGroup(string $cid): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #6c757d;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-slash-circle me-2" style="color: #6c757d;"></i>';
$html .= ' Exclusiones y Rendimiento';
$html .= ' </h5>';
// Accordion para exclusiones
$html .= '<div class="accordion accordion-flush" id="exclusionsAccordion">';
// Exclusiones
$html .= '<div class="accordion-item">';
$html .= ' <h2 class="accordion-header">';
$html .= ' <button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#exclusionsCollapse">';
$html .= ' <i class="bi bi-funnel me-2"></i> Filtros de exclusion';
$html .= ' </button>';
$html .= ' </h2>';
$html .= ' <div id="exclusionsCollapse" class="accordion-collapse collapse" data-bs-parent="#exclusionsAccordion">';
$html .= ' <div class="accordion-body">';
$excludeCats = $this->renderer->getFieldValue($cid, 'forms', 'exclude_categories', '');
$html .= $this->buildTextarea($cid . 'ExcludeCategories', 'Excluir categorias (IDs)', $excludeCats, 'Ej: 5,12,23');
$excludeTypes = $this->renderer->getFieldValue($cid, 'forms', 'exclude_post_types', '');
$html .= $this->buildTextarea($cid . 'ExcludePostTypes', 'Excluir tipos de post', $excludeTypes, 'Ej: page,attachment');
$excludeIds = $this->renderer->getFieldValue($cid, 'forms', 'exclude_post_ids', '');
$html .= $this->buildTextarea($cid . 'ExcludePostIds', 'Excluir posts (IDs)', $excludeIds, 'Ej: 100,205,310');
$minLength = $this->renderer->getFieldValue($cid, 'forms', 'min_content_length', '500');
$html .= $this->buildTextInput($cid . 'MinContentLength', 'Longitud minima de contenido', $minLength);
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
$html .= '</div>'; // end accordion
// Delay settings (siempre visibles)
$html .= '<hr class="my-3">';
$html .= '<p class="small text-muted mb-2"><i class="bi bi-speedometer2 me-1"></i> Rendimiento:</p>';
$delayEnabled = $this->renderer->getFieldValue($cid, 'forms', 'delay_enabled', true);
$html .= $this->buildSwitch($cid . 'DelayEnabled', 'Retrasar carga (mejor PageSpeed)', $delayEnabled, 'bi-hourglass-split');
$delayTimeout = $this->renderer->getFieldValue($cid, 'forms', 'delay_timeout', '5000');
$html .= $this->buildTextInput($cid . 'DelayTimeout', 'Timeout de delay (ms)', $delayTimeout);
$html .= ' </div>';
$html .= '</div>';
return $html;
}
// === HELPERS ===
private function buildSwitch(string $id, string $label, $value, string $icon = ''): string
{
$checked = checked($value, true, false);
$iconHtml = $icon ? '<i class="bi ' . $icon . ' me-1" style="color: #FF8600;"></i>' : '';
return sprintf(
'<div class="mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="%s" %s>
<label class="form-check-label small" for="%s">%s%s</label>
</div>
</div>',
esc_attr($id), $checked, esc_attr($id), $iconHtml, esc_html($label)
);
}
private function buildTextInput(string $id, string $label, string $value, string $placeholder = ''): string
{
return sprintf(
'<div class="mb-3">
<label for="%s" class="form-label small fw-semibold">%s</label>
<input type="text" class="form-control form-control-sm" id="%s" value="%s" placeholder="%s">
</div>',
esc_attr($id), esc_html($label), esc_attr($id), esc_attr($value), esc_attr($placeholder)
);
}
private function buildTextarea(string $id, string $label, string $value, string $placeholder = ''): string
{
return sprintf(
'<div class="mb-3">
<label for="%s" class="form-label small fw-semibold">%s</label>
<textarea class="form-control form-control-sm" id="%s" rows="2" placeholder="%s">%s</textarea>
</div>',
esc_attr($id), esc_html($label), esc_attr($id), esc_attr($placeholder), esc_textarea($value)
);
}
private function buildSelect(string $id, string $label, string $value, array $options): string
{
$optionsHtml = '';
foreach ($options as $optValue => $optLabel) {
$selected = selected($value, $optValue, false);
$optionsHtml .= sprintf(
'<option value="%s" %s>%s</option>',
esc_attr($optValue),
$selected,
esc_html($optLabel)
);
}
return sprintf(
'<div class="mb-2">
<label for="%s" class="form-label small fw-semibold">%s</label>
<select class="form-select form-select-sm" id="%s">%s</select>
</div>',
esc_attr($id), esc_html($label), esc_attr($id), $optionsHtml
);
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
{
$checked = checked($value, true, false);
return sprintf(
'<div class="form-check">
<input class="form-check-input" type="checkbox" id="%s" %s>
<label class="form-check-label small" for="%s">
<i class="bi %s me-1" style="color: #6c757d;"></i>%s
</label>
</div>',
esc_attr($id),
$checked,
esc_attr($id),
esc_attr($icon),
esc_html($label)
);
}
}

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,81 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ArchiveHeader\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Archive Header
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class ArchiveHeaderFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'archive-header';
}
public function getFieldMapping(): array
{
return [
// Visibility
'archiveHeaderEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'archiveHeaderShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'archiveHeaderShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Page Visibility (grupo especial _page_visibility)
'archiveHeaderVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'archiveHeaderVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'archiveHeaderVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'archiveHeaderVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'archiveHeaderVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions)
'archiveHeaderExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'archiveHeaderExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'archiveHeaderExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'archiveHeaderExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'archiveHeaderBlogTitle' => ['group' => 'content', 'attribute' => 'blog_title'],
'archiveHeaderShowPostCount' => ['group' => 'content', 'attribute' => 'show_post_count'],
'archiveHeaderShowDescription' => ['group' => 'content', 'attribute' => 'show_description'],
'archiveHeaderCategoryPrefix' => ['group' => 'content', 'attribute' => 'category_prefix'],
'archiveHeaderTagPrefix' => ['group' => 'content', 'attribute' => 'tag_prefix'],
'archiveHeaderAuthorPrefix' => ['group' => 'content', 'attribute' => 'author_prefix'],
'archiveHeaderDatePrefix' => ['group' => 'content', 'attribute' => 'date_prefix'],
'archiveHeaderSearchPrefix' => ['group' => 'content', 'attribute' => 'search_prefix'],
'archiveHeaderCountSingular' => ['group' => 'content', 'attribute' => 'posts_count_singular'],
'archiveHeaderCountPlural' => ['group' => 'content', 'attribute' => 'posts_count_plural'],
// Typography
'archiveHeaderHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
'archiveHeaderTitleSize' => ['group' => 'typography', 'attribute' => 'title_size'],
'archiveHeaderTitleWeight' => ['group' => 'typography', 'attribute' => 'title_weight'],
'archiveHeaderDescriptionSize' => ['group' => 'typography', 'attribute' => 'description_size'],
'archiveHeaderCountSize' => ['group' => 'typography', 'attribute' => 'count_size'],
// Colors
'archiveHeaderTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
'archiveHeaderPrefixColor' => ['group' => 'colors', 'attribute' => 'prefix_color'],
'archiveHeaderDescriptionColor' => ['group' => 'colors', 'attribute' => 'description_color'],
'archiveHeaderCountBgColor' => ['group' => 'colors', 'attribute' => 'count_bg_color'],
'archiveHeaderCountTextColor' => ['group' => 'colors', 'attribute' => 'count_text_color'],
// Spacing
'archiveHeaderMarginTop' => ['group' => 'spacing', 'attribute' => 'margin_top'],
'archiveHeaderMarginBottom' => ['group' => 'spacing', 'attribute' => 'margin_bottom'],
'archiveHeaderPadding' => ['group' => 'spacing', 'attribute' => 'padding'],
'archiveHeaderTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
'archiveHeaderCountPadding' => ['group' => 'spacing', 'attribute' => 'count_padding'],
// Behavior
'archiveHeaderIsSticky' => ['group' => 'behavior', 'attribute' => 'is_sticky'],
'archiveHeaderStickyOffset' => ['group' => 'behavior', 'attribute' => 'sticky_offset'],
];
}
}

View File

@@ -0,0 +1,492 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ArchiveHeader\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Archive Header
*
* @package ROITheme\Admin\ArchiveHeader\Infrastructure\Ui
*/
final class ArchiveHeaderFormBuilder
{
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 .= '</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 me-2" style="color: #FF8600;"></i>';
$html .= ' Configuracion de Cabecera de Archivo';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Cabecera dinamica para paginas de listados (blog, categorias, tags, autor, fecha, busqueda)';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="archive-header">';
$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('archiveHeaderEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('archiveHeaderShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('archiveHeaderShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// Page visibility checkboxes
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', false);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// Exclusions
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'archiveHeader');
$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>';
// Blog Title
$blogTitle = $this->renderer->getFieldValue($componentId, 'content', 'blog_title', 'Blog');
$html .= ' <div class="mb-3">';
$html .= ' <label for="archiveHeaderBlogTitle" class="form-label small mb-1 fw-semibold">Titulo del blog</label>';
$html .= ' <input type="text" id="archiveHeaderBlogTitle" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($blogTitle) . '">';
$html .= ' <small class="text-muted">Mostrado en la pagina principal del blog</small>';
$html .= ' </div>';
// Switches
$showPostCount = $this->renderer->getFieldValue($componentId, 'content', 'show_post_count', true);
$html .= $this->buildSwitch('archiveHeaderShowPostCount', 'Mostrar contador de posts', 'bi-hash', $showPostCount);
$showDescription = $this->renderer->getFieldValue($componentId, 'content', 'show_description', true);
$html .= $this->buildSwitch('archiveHeaderShowDescription', 'Mostrar descripcion', 'bi-text-paragraph', $showDescription);
// Prefixes section
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-tag me-1" style="color: #FF8600;"></i>';
$html .= ' Prefijos de titulo';
$html .= ' </p>';
$html .= ' <div class="row g-2">';
$categoryPrefix = $this->renderer->getFieldValue($componentId, 'content', 'category_prefix', 'Categoria:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCategoryPrefix" class="form-label small mb-1">Categoria</label>';
$html .= ' <input type="text" id="archiveHeaderCategoryPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($categoryPrefix) . '">';
$html .= ' </div>';
$tagPrefix = $this->renderer->getFieldValue($componentId, 'content', 'tag_prefix', 'Etiqueta:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTagPrefix" class="form-label small mb-1">Etiqueta</label>';
$html .= ' <input type="text" id="archiveHeaderTagPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($tagPrefix) . '">';
$html .= ' </div>';
$authorPrefix = $this->renderer->getFieldValue($componentId, 'content', 'author_prefix', 'Articulos de:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderAuthorPrefix" class="form-label small mb-1">Autor</label>';
$html .= ' <input type="text" id="archiveHeaderAuthorPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($authorPrefix) . '">';
$html .= ' </div>';
$datePrefix = $this->renderer->getFieldValue($componentId, 'content', 'date_prefix', 'Archivo:');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderDatePrefix" class="form-label small mb-1">Fecha</label>';
$html .= ' <input type="text" id="archiveHeaderDatePrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($datePrefix) . '">';
$html .= ' </div>';
$searchPrefix = $this->renderer->getFieldValue($componentId, 'content', 'search_prefix', 'Resultados para:');
$html .= ' <div class="col-12">';
$html .= ' <label for="archiveHeaderSearchPrefix" class="form-label small mb-1">Busqueda</label>';
$html .= ' <input type="text" id="archiveHeaderSearchPrefix" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($searchPrefix) . '">';
$html .= ' </div>';
$html .= ' </div>';
// Post count texts
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-123 me-1" style="color: #FF8600;"></i>';
$html .= ' Textos del contador';
$html .= ' </p>';
$html .= ' <div class="row g-2">';
$countSingular = $this->renderer->getFieldValue($componentId, 'content', 'posts_count_singular', 'publicacion');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCountSingular" class="form-label small mb-1">Singular</label>';
$html .= ' <input type="text" id="archiveHeaderCountSingular" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countSingular) . '">';
$html .= ' </div>';
$countPlural = $this->renderer->getFieldValue($componentId, 'content', 'posts_count_plural', 'publicaciones');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCountPlural" class="form-label small mb-1">Plural</label>';
$html .= ' <input type="text" id="archiveHeaderCountPlural" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countPlural) . '">';
$html .= ' </div>';
$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-gear me-2" style="color: #FF8600;"></i>';
$html .= ' Comportamiento';
$html .= ' </h5>';
$isSticky = $this->renderer->getFieldValue($componentId, 'behavior', 'is_sticky', false);
$html .= $this->buildSwitch('archiveHeaderIsSticky', 'Header fijo al hacer scroll', 'bi-pin-angle', $isSticky);
$stickyOffset = $this->renderer->getFieldValue($componentId, 'behavior', 'sticky_offset', '0');
$html .= ' <div class="mb-0">';
$html .= ' <label for="archiveHeaderStickyOffset" class="form-label small mb-1 fw-semibold">Offset sticky</label>';
$html .= ' <input type="text" id="archiveHeaderStickyOffset" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($stickyOffset) . '">';
$html .= ' <small class="text-muted">Distancia desde el top cuando es sticky (ej: 60px)</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>';
// Heading level
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h1');
$html .= ' <div class="mb-3">';
$html .= ' <label for="archiveHeaderHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
$html .= ' <select id="archiveHeaderHeadingLevel" class="form-select form-select-sm">';
foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $level) {
$selected = $headingLevel === $level ? ' selected' : '';
$html .= sprintf(' <option value="%s"%s>%s</option>', $level, $selected, strtoupper($level));
}
$html .= ' </select>';
$html .= ' <small class="text-muted">Importante para SEO y accesibilidad</small>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$titleSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_size', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="archiveHeaderTitleSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleSize) . '">';
$html .= ' </div>';
$titleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_weight', '700');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="archiveHeaderTitleWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$descriptionSize = $this->renderer->getFieldValue($componentId, 'typography', 'description_size', '1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderDescriptionSize" class="form-label small mb-1 fw-semibold">Tamano descripcion</label>';
$html .= ' <input type="text" id="archiveHeaderDescriptionSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($descriptionSize) . '">';
$html .= ' </div>';
$countSize = $this->renderer->getFieldValue($componentId, 'typography', 'count_size', '0.875rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderCountSize" class="form-label small mb-1 fw-semibold">Tamano contador</label>';
$html .= ' <input type="text" id="archiveHeaderCountSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countSize) . '">';
$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>';
$html .= ' <div class="row g-2 mb-3">';
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#0E2337');
$html .= $this->buildColorPicker('archiveHeaderTitleColor', 'Titulo', $titleColor);
$prefixColor = $this->renderer->getFieldValue($componentId, 'colors', 'prefix_color', '#6b7280');
$html .= $this->buildColorPicker('archiveHeaderPrefixColor', 'Prefijo', $prefixColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$descriptionColor = $this->renderer->getFieldValue($componentId, 'colors', 'description_color', '#6b7280');
$html .= $this->buildColorPicker('archiveHeaderDescriptionColor', 'Descripcion', $descriptionColor);
$html .= ' </div>';
$html .= ' <p class="small fw-semibold mb-2">Contador de posts</p>';
$html .= ' <div class="row g-2 mb-0">';
$countBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'count_bg_color', '#FF8600');
$html .= $this->buildColorPicker('archiveHeaderCountBgColor', 'Fondo', $countBgColor);
$countTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'count_text_color', '#ffffff');
$html .= $this->buildColorPicker('archiveHeaderCountTextColor', 'Texto', $countTextColor);
$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', 'margin_top', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="archiveHeaderMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginTop) . '">';
$html .= ' </div>';
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_bottom', '2rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="archiveHeaderMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($marginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$padding = $this->renderer->getFieldValue($componentId, 'spacing', 'padding', '1.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderPadding" class="form-label small mb-1 fw-semibold">Padding</label>';
$html .= ' <input type="text" id="archiveHeaderPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($padding) . '">';
$html .= ' </div>';
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '0.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="archiveHeaderTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
$html .= ' <input type="text" id="archiveHeaderTitleMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="mb-0">';
$countPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'count_padding', '0.25rem 0.75rem');
$html .= ' <label for="archiveHeaderCountPadding" class="form-label small mb-1 fw-semibold">Padding contador</label>';
$html .= ' <input type="text" id="archiveHeaderCountPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($countPadding) . '">';
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,110 @@
<?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'],
// Page Visibility (grupo especial _page_visibility)
'contactFormVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'contactFormVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'contactFormVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'contactFormVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'contactFormExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'contactFormExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'contactFormExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'contactFormExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,652 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'contactForm');
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,89 @@
<?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'],
'ctaHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'ctaVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'ctaExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'ctaExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'ctaExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'ctaExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,587 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
// Obtener valores de _page_visibility (grupo especial)
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
// Grid 3 columnas según Design System
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'cta');
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$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;
}
/**
* Genera un checkbox de visibilidad por tipo de pagina
*
* Sigue Design System: form-check-checkbox es obligatorio
*/
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, bool $checked): string
{
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,81 @@
<?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'],
'ctaLetsTalkHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'ctaLetsTalkVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaLetsTalkVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaLetsTalkVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaLetsTalkVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaLetsTalkVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'letsTalkExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'letsTalkExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'letsTalkExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'letsTalkExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,530 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'letsTalk');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkIsCritical" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkIsCritical" style="color: #495057;">';
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,82 @@
<?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'],
'ctaPostHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'ctaPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'ctaPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'ctaPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'ctaPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'ctaPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,505 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'ctaPost');
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaPostHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaPostHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesion iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\DTOs;
/**
* DTO para solicitud de guardado de snippet
*
* Inmutable - una vez creado no puede modificarse.
* Transporta datos desde Infrastructure (form) hacia Application (use case).
*/
final class SaveSnippetRequest
{
/**
* @param string $id ID único del snippet (nuevo o existente)
* @param string $name Nombre descriptivo
* @param string $description Descripción opcional
* @param string $css Código CSS
* @param string $type Tipo de carga: 'critical' | 'deferred'
* @param array<string> $pages Páginas donde aplicar: ['all'], ['home', 'posts'], etc.
* @param bool $enabled Si el snippet está activo
* @param int $order Orden de carga (menor = primero)
*/
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $description,
public readonly string $css,
public readonly string $type,
public readonly array $pages,
public readonly bool $enabled,
public readonly int $order
) {}
/**
* Factory desde array (formulario o API)
*
* @param array $data Datos del formulario
* @return self
*/
public static function fromArray(array $data): self
{
return new self(
id: $data['id'] ?? '',
name: $data['name'] ?? '',
description: $data['description'] ?? '',
css: $data['css'] ?? '',
type: $data['type'] ?? 'deferred',
pages: $data['pages'] ?? ['all'],
enabled: (bool)($data['enabled'] ?? true),
order: (int)($data['order'] ?? 100)
);
}
/**
* Convierte a array para persistencia
*
* @return array
*/
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'css' => $this->css,
'type' => $this->type,
'pages' => $this->pages,
'enabled' => $this->enabled,
'order' => $this->order,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\UseCases;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Caso de uso: Eliminar snippet CSS
*
* SRP: Solo responsable de orquestar la eliminación
*/
final class DeleteSnippetUseCase
{
public function __construct(
private readonly CSSSnippetRepositoryInterface $repository
) {}
/**
* Ejecuta la eliminación del snippet
*
* @param string $snippetId ID del snippet a eliminar
* @return void
*/
public function execute(string $snippetId): void
{
$this->repository->delete($snippetId);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\UseCases;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Caso de uso: Obtener todos los snippets (para Admin UI)
*
* SRP: Solo responsable de obtener lista completa
*/
final class GetAllSnippetsUseCase
{
public function __construct(
private readonly CSSSnippetRepositoryInterface $repository
) {}
/**
* Ejecuta la obtención de todos los snippets
*
* @return array<array> Lista de snippets ordenados por 'order'
*/
public function execute(): array
{
return $this->repository->getAll();
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\UseCases;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\Entities\CSSSnippet;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Caso de uso: Guardar snippet CSS
*
* SRP: Solo responsable de orquestar el guardado
*/
final class SaveSnippetUseCase
{
public function __construct(
private readonly CSSSnippetRepositoryInterface $repository
) {}
public function execute(SaveSnippetRequest $request): void
{
// 1. Crear entidad desde DTO
$snippet = CSSSnippet::fromArray($request->toArray());
// 2. Validar en dominio
$snippet->validate();
// 3. Validar tamaño según tipo
$snippet->css()->validateForLoadType($snippet->loadType());
// 4. Persistir
$this->repository->save($snippet->toArray());
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\Entities;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\CSSCode;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\LoadType;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Entidad de dominio para snippet CSS (contexto Admin)
*
* Responsabilidad: Reglas de negocio para ADMINISTRAR snippets
*/
final class CSSSnippet
{
private function __construct(
private readonly SnippetId $id,
private readonly string $name,
private readonly string $description,
private readonly CSSCode $css,
private readonly LoadType $loadType,
private readonly array $pages,
private readonly bool $enabled,
private readonly int $order
) {}
/**
* Factory method desde array (BD)
*/
public static function fromArray(array $data): self
{
return new self(
SnippetId::fromString($data['id']),
$data['name'],
$data['description'] ?? '',
CSSCode::fromString($data['css']),
LoadType::fromString($data['type']),
$data['pages'] ?? ['all'],
$data['enabled'] ?? true,
$data['order'] ?? 100
);
}
/**
* Valida que el snippet pueda ser guardado
* @throws ValidationException
*/
public function validate(): void
{
if (empty($this->name)) {
throw new ValidationException('El nombre del snippet es requerido');
}
if (strlen($this->name) > 100) {
throw new ValidationException('El nombre no puede exceder 100 caracteres');
}
// CSS ya validado en Value Object CSSCode
}
/**
* Convierte a array para persistencia
*/
public function toArray(): array
{
return [
'id' => $this->id->value(),
'name' => $this->name,
'description' => $this->description,
'css' => $this->css->value(),
'type' => $this->loadType->value(),
'pages' => $this->pages,
'enabled' => $this->enabled,
'order' => $this->order,
];
}
// Getters
public function id(): SnippetId
{
return $this->id;
}
public function name(): string
{
return $this->name;
}
public function css(): CSSCode
{
return $this->css;
}
public function loadType(): LoadType
{
return $this->loadType;
}
public function pages(): array
{
return $this->pages;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function order(): int
{
return $this->order;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para código CSS validado
*/
final class CSSCode
{
private const MAX_SIZE_CRITICAL = 14336; // 14KB para CSS crítico
private const MAX_SIZE_DEFERRED = 102400; // 100KB para CSS diferido
private function __construct(
private readonly string $value
) {}
public static function fromString(string $css): self
{
$sanitized = self::sanitize($css);
self::validate($sanitized);
return new self($sanitized);
}
private static function sanitize(string $css): string
{
// Eliminar etiquetas <style>
$css = preg_replace('/<\/?style[^>]*>/i', '', $css);
// Eliminar comentarios HTML
$css = preg_replace('/<!--.*?-->/s', '', $css);
return trim($css);
}
private static function validate(string $css): void
{
// Detectar código potencialmente peligroso
$dangerous = ['javascript:', 'expression(', '@import', 'behavior:'];
foreach ($dangerous as $pattern) {
if (stripos($css, $pattern) !== false) {
throw new ValidationException("CSS contiene patrón no permitido: {$pattern}");
}
}
}
public function validateForLoadType(LoadType $loadType): void
{
$maxSize = $loadType->isCritical()
? self::MAX_SIZE_CRITICAL
: self::MAX_SIZE_DEFERRED;
if (strlen($this->value) > $maxSize) {
throw new ValidationException(
sprintf('CSS excede el tamaño máximo de %d bytes para tipo %s',
$maxSize,
$loadType->value()
)
);
}
}
public function value(): string
{
return $this->value;
}
public function isEmpty(): bool
{
return empty($this->value);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para tipo de carga CSS
*/
final class LoadType
{
private const VALID_TYPES = ['critical', 'deferred'];
private function __construct(
private readonly string $value
) {}
public static function fromString(string $value): self
{
if (!in_array($value, self::VALID_TYPES, true)) {
throw new ValidationException(
sprintf('LoadType inválido: %s. Valores válidos: %s',
$value,
implode(', ', self::VALID_TYPES)
)
);
}
return new self($value);
}
public static function critical(): self
{
return new self('critical');
}
public static function deferred(): self
{
return new self('deferred');
}
public function isCritical(): bool
{
return $this->value === 'critical';
}
public function isDeferred(): bool
{
return $this->value === 'deferred';
}
public function value(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para ID único de snippet CSS
*
* Soporta tres formatos:
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
* 2. Legacy con prefijo: css_[descriptive]_[number] (ej: "css_tablas_apu_1764624826")
* 3. Legacy kebab-case: (ej: "cls-tables-apu", "generic-tables")
*
* Esto permite migrar snippets existentes sin romper IDs.
*/
final class SnippetId
{
private const PREFIX = 'css_';
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/';
private const PATTERN_LEGACY_PREFIX = '/^css_[a-z0-9_]+$/';
private const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
private function __construct(
private readonly string $value
) {}
/**
* Crea SnippetId desde string existente (desde BD)
*
* Acepta tanto IDs generados (css_*) como IDs legacy (kebab-case).
*
* @param string $id ID existente
* @return self
* @throws ValidationException Si el formato es inválido
*/
public static function fromString(string $id): self
{
$id = trim($id);
if (empty($id)) {
throw new ValidationException('El ID del snippet no puede estar vacío');
}
if (strlen($id) > 50) {
throw new ValidationException('El ID del snippet no puede exceder 50 caracteres');
}
// Validar formato generado (css_*)
if (str_starts_with($id, self::PREFIX)) {
// Acepta formato nuevo (css_timestamp_random) o legacy (css_descriptivo_numero)
if (!preg_match(self::PATTERN_GENERATED, $id) && !preg_match(self::PATTERN_LEGACY_PREFIX, $id)) {
throw new ValidationException(
sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id)
);
}
return new self($id);
}
// Validar formato legacy (kebab-case)
if (!preg_match(self::PATTERN_LEGACY, $id)) {
throw new ValidationException(
sprintf('Formato de ID inválido: %s. Use kebab-case (ej: cls-tables-apu)', $id)
);
}
return new self($id);
}
/**
* Genera un nuevo SnippetId único
*
* @return self
*/
public static function generate(): self
{
$timestamp = time();
$random = bin2hex(random_bytes(3));
return new self(self::PREFIX . $timestamp . '_' . $random);
}
/**
* Verifica si es un ID generado (vs legacy)
*
* @return bool
*/
public function isGenerated(): bool
{
return str_starts_with($this->value, self::PREFIX);
}
/**
* Obtiene el valor del ID
*
* @return string
*/
public function value(): string
{
return $this->value;
}
/**
* Compara igualdad con otro SnippetId
*
* @param SnippetId $other
* @return bool
*/
public function equals(SnippetId $other): bool
{
return $this->value === $other->value;
}
/**
* Representación string
*
* @return string
*/
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Bootstrap;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Bootstrap para CustomCSSManager
*
* Registra el handler de formulario POST en admin_init
* ANTES de que se envíen headers HTTP
*/
final class CustomCSSManagerBootstrap
{
private const NONCE_ACTION = 'roi_custom_css_manager';
public static function init(): void
{
add_action('admin_init', [self::class, 'handleFormSubmission']);
}
public static function handleFormSubmission(): void
{
if (!isset($_POST['roi_css_action'])) {
return;
}
// Verificar que estamos en la página correcta
$page = $_GET['page'] ?? '';
$component = $_GET['component'] ?? '';
if ($page !== 'roi-theme-admin' || $component !== 'custom-css-manager') {
return;
}
// Verificar nonce
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
wp_die('Nonce verification failed');
}
// Verificar permisos
if (!current_user_can('manage_options')) {
wp_die('Insufficient permissions');
}
global $wpdb;
$repository = new WordPressSnippetRepository($wpdb);
$saveUseCase = new SaveSnippetUseCase($repository);
$deleteUseCase = new DeleteSnippetUseCase($repository);
$action = sanitize_text_field($_POST['roi_css_action']);
try {
match ($action) {
'save' => self::processSave($_POST, $saveUseCase),
'delete' => self::processDelete($_POST, $deleteUseCase),
default => null,
};
// Redirect con mensaje de éxito
$redirect_url = admin_url('admin.php?page=roi-theme-admin&component=custom-css-manager&roi_message=success');
wp_redirect($redirect_url);
exit;
} catch (ValidationException $e) {
$redirect_url = admin_url('admin.php?page=roi-theme-admin&component=custom-css-manager&roi_message=error&roi_error=' . urlencode($e->getMessage()));
wp_redirect($redirect_url);
exit;
}
}
private static function processSave(array $data, SaveSnippetUseCase $useCase): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
if (empty($id)) {
$id = SnippetId::generate()->value();
}
$request = SaveSnippetRequest::fromArray([
'id' => $id,
'name' => sanitize_text_field($data['snippet_name'] ?? ''),
'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
'enabled' => isset($data['snippet_enabled']),
'order' => absint($data['snippet_order'] ?? 100),
]);
$useCase->execute($request);
}
private static function processDelete(array $data, DeleteSnippetUseCase $useCase): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
if (empty($id)) {
throw new ValidationException('ID de snippet requerido para eliminar');
}
$useCase->execute($id);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Repositorio WordPress para snippets CSS
*
* Almacena snippets como JSON en wp_roi_theme_component_settings
*/
final class WordPressSnippetRepository implements CSSSnippetRepositoryInterface
{
private const COMPONENT_NAME = 'custom-css-manager';
private const GROUP_NAME = 'css_snippets';
private const ATTRIBUTE_NAME = 'snippets_json';
public function __construct(
private readonly \wpdb $wpdb
) {}
public function getAll(): array
{
$tableName = $this->wpdb->prefix . 'roi_theme_component_settings';
$sql = $this->wpdb->prepare(
"SELECT attribute_value FROM {$tableName}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s
LIMIT 1",
self::COMPONENT_NAME,
self::GROUP_NAME,
self::ATTRIBUTE_NAME
);
$json = $this->wpdb->get_var($sql);
if (empty($json)) {
return [];
}
$snippets = json_decode($json, true);
return is_array($snippets) ? $snippets : [];
}
public function getByLoadType(string $loadType): array
{
$all = $this->getAll();
$filtered = array_filter($all, function ($snippet) use ($loadType) {
return ($snippet['type'] ?? '') === $loadType
&& ($snippet['enabled'] ?? false) === true;
});
// Reindexar para evitar keys dispersas [0,2,5] → [0,1,2]
return array_values($filtered);
}
public function getForPage(string $loadType, string $pageType): array
{
$snippets = $this->getByLoadType($loadType);
$filtered = array_filter($snippets, function ($snippet) use ($pageType) {
$pages = $snippet['pages'] ?? ['all'];
return in_array('all', $pages, true)
|| in_array($pageType, $pages, true);
});
// Reindexar para evitar keys dispersas
return array_values($filtered);
}
public function save(array $snippet): void
{
$all = $this->getAll();
// Actualizar o agregar
$found = false;
foreach ($all as &$existing) {
if ($existing['id'] === $snippet['id']) {
$existing = $snippet;
$found = true;
break;
}
}
if (!$found) {
$all[] = $snippet;
}
// Ordenar por 'order'
usort($all, fn($a, $b) => ($a['order'] ?? 100) <=> ($b['order'] ?? 100));
$this->persist($all);
}
public function delete(string $snippetId): void
{
$all = $this->getAll();
$filtered = array_filter($all, fn($s) => $s['id'] !== $snippetId);
$this->persist(array_values($filtered));
}
/**
* Persiste la lista de snippets en BD
*
* Usa update() + insert() para consistencia con patrón existente.
* NOTA: NO usa replace() porque:
* - Preserva ID autoincremental
* - Preserva campos como is_editable, created_at
*
* @param array $snippets Lista de snippets a persistir
*/
private function persist(array $snippets): void
{
$tableName = $this->wpdb->prefix . 'roi_theme_component_settings';
$json = wp_json_encode($snippets, JSON_UNESCAPED_UNICODE);
// Verificar si el registro existe
$exists = $this->wpdb->get_var($this->wpdb->prepare(
"SELECT COUNT(*) FROM {$tableName}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s",
self::COMPONENT_NAME,
self::GROUP_NAME,
self::ATTRIBUTE_NAME
));
if ($exists > 0) {
// UPDATE existente (preserva id, created_at, is_editable)
$this->wpdb->update(
$tableName,
['attribute_value' => $json],
[
'component_name' => self::COMPONENT_NAME,
'group_name' => self::GROUP_NAME,
'attribute_name' => self::ATTRIBUTE_NAME,
],
['%s'],
['%s', '%s', '%s']
);
} else {
// INSERT nuevo
$this->wpdb->insert(
$tableName,
[
'component_name' => self::COMPONENT_NAME,
'group_name' => self::GROUP_NAME,
'attribute_name' => self::ATTRIBUTE_NAME,
'attribute_value' => $json,
'is_editable' => 1,
],
['%s', '%s', '%s', '%s', '%d']
);
}
}
}

View File

@@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
/**
* FormBuilder para gestión de CSS snippets en Admin Panel
*
* Sigue el patrón estándar de FormBuilders del tema:
* - Constructor recibe AdminDashboardRenderer
* - Método buildForm() genera el HTML del formulario
*
* NOTA: El handler de formulario POST está en CustomCSSManagerBootstrap
* para que se ejecute en admin_init ANTES de que se envíen headers HTTP.
*
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
*/
final class CustomCSSManagerFormBuilder
{
private const COMPONENT_ID = 'custom-css-manager';
private const NONCE_ACTION = 'roi_custom_css_manager';
private WordPressSnippetRepository $repository;
private GetAllSnippetsUseCase $getAllUseCase;
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {
// Crear repositorio y Use Case para listar snippets
global $wpdb;
$this->repository = new WordPressSnippetRepository($wpdb);
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
// NOTA: El handler POST está en CustomCSSManagerBootstrap (admin_init)
}
/**
* Construye el formulario del componente
*
* @param string $componentId ID del componente (custom-css-manager)
* @return string HTML del formulario
*/
public function buildForm(string $componentId): string
{
$snippets = $this->getAllUseCase->execute();
$message = $this->getFlashMessage();
$html = '';
// Header
$html .= $this->buildHeader($componentId, count($snippets));
// Toast para mensajes (usa el sistema existente de admin-dashboard.js)
if ($message) {
$html .= $this->buildToastTrigger($message);
}
// Lista de snippets existentes
$html .= $this->buildSnippetsList($snippets);
// Formulario de creación/edición
$html .= $this->buildSnippetForm();
// JavaScript
$html .= $this->buildJavaScript();
return $html;
}
/**
* Construye el header del componente
*/
private function buildHeader(string $componentId, int $snippetCount): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-header d-flex justify-content-between align-items-center" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%);">';
$html .= ' <h3 class="mb-0 text-white"><i class="bi bi-file-earmark-code me-2"></i>Gestor de CSS Personalizado</h3>';
$html .= ' <span class="badge bg-light text-dark">' . esc_html($snippetCount) . ' snippet(s)</span>';
$html .= ' </div>';
$html .= ' <div class="card-body">';
$html .= ' <p class="text-muted mb-0">Gestiona snippets de CSS personalizados. Los snippets críticos se cargan en el head, los diferidos en el footer.</p>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Construye la lista de snippets existentes
*/
private function buildSnippetsList(array $snippets): string
{
$html = '<div class="card shadow-sm mb-4" 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-list-ul me-2" style="color: #FF8600;"></i>';
$html .= ' Snippets Configurados';
$html .= ' </h5>';
if (empty($snippets)) {
$html .= ' <p class="text-muted">No hay snippets configurados.</p>';
} else {
$html .= ' <div class="table-responsive">';
$html .= ' <table class="table table-hover">';
$html .= ' <thead>';
$html .= ' <tr>';
$html .= ' <th>Nombre</th>';
$html .= ' <th>Tipo</th>';
$html .= ' <th>Páginas</th>';
$html .= ' <th>Estado</th>';
$html .= ' <th>Acciones</th>';
$html .= ' </tr>';
$html .= ' </thead>';
$html .= ' <tbody>';
foreach ($snippets as $snippet) {
$html .= $this->renderSnippetRow($snippet);
}
$html .= ' </tbody>';
$html .= ' </table>';
$html .= ' </div>';
}
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Renderiza una fila de snippet en la tabla
*/
private function renderSnippetRow(array $snippet): string
{
$id = esc_attr($snippet['id']);
$name = esc_html($snippet['name']);
$type = $snippet['type'] === 'critical' ? 'Crítico' : 'Diferido';
$typeBadge = $snippet['type'] === 'critical' ? 'bg-danger' : 'bg-info';
$pages = implode(', ', $snippet['pages'] ?? ['all']);
$enabled = ($snippet['enabled'] ?? false) ? 'Activo' : 'Inactivo';
$enabledBadge = ($snippet['enabled'] ?? false) ? 'bg-success' : 'bg-secondary';
// Usar data-attribute para JSON seguro
$snippetJson = esc_attr(wp_json_encode($snippet, JSON_HEX_APOS | JSON_HEX_QUOT));
$nonce = wp_create_nonce(self::NONCE_ACTION);
return <<<HTML
<tr>
<td><strong>{$name}</strong></td>
<td><span class="badge {$typeBadge}">{$type}</span></td>
<td><small>{$pages}</small></td>
<td><span class="badge {$enabledBadge}">{$enabled}</span></td>
<td>
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-snippet"
data-snippet="{$snippetJson}">
<i class="bi bi-pencil"></i>
</button>
<form method="post" class="d-inline"
onsubmit="return confirm('¿Eliminar este snippet?');">
<input type="hidden" name="_wpnonce" value="{$nonce}">
<input type="hidden" name="roi_css_action" value="delete">
<input type="hidden" name="snippet_id" value="{$id}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
HTML;
}
/**
* Construye el formulario de creación/edición de snippets
*/
private function buildSnippetForm(): string
{
$nonce = wp_create_nonce(self::NONCE_ACTION);
$html = '<div class="card shadow-sm mb-4" 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-plus-circle me-2" style="color: #FF8600;"></i>';
$html .= ' Agregar/Editar Snippet';
$html .= ' </h5>';
$html .= ' <form method="post" id="roi-snippet-form">';
$html .= ' <input type="hidden" name="_wpnonce" value="' . esc_attr($nonce) . '">';
$html .= ' <input type="hidden" name="roi_css_action" value="save">';
$html .= ' <input type="hidden" name="snippet_id" id="snippet_id" value="">';
$html .= ' <div class="row g-3">';
// Nombre
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Nombre *</label>';
$html .= ' <input type="text" name="snippet_name" id="snippet_name" class="form-control" required maxlength="100" placeholder="Ej: Estilos Tablas APU">';
$html .= ' </div>';
// Tipo
$html .= ' <div class="col-md-3">';
$html .= ' <label class="form-label">Tipo *</label>';
$html .= ' <select name="snippet_type" id="snippet_type" class="form-select">';
$html .= ' <option value="critical">Crítico (head)</option>';
$html .= ' <option value="deferred" selected>Diferido (footer)</option>';
$html .= ' </select>';
$html .= ' </div>';
// Orden
$html .= ' <div class="col-md-3">';
$html .= ' <label class="form-label">Orden</label>';
$html .= ' <input type="number" name="snippet_order" id="snippet_order" class="form-control" value="100" min="1" max="999">';
$html .= ' </div>';
// Descripción
$html .= ' <div class="col-12">';
$html .= ' <label class="form-label">Descripción</label>';
$html .= ' <input type="text" name="snippet_description" id="snippet_description" class="form-control" maxlength="255" placeholder="Descripción breve del propósito del CSS">';
$html .= ' </div>';
// Páginas
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Aplicar en páginas</label>';
$html .= ' <div class="d-flex flex-wrap gap-2">';
foreach ($this->getPageOptions() as $value => $label) {
$checked = $value === 'all' ? 'checked' : '';
$html .= sprintf(
'<div class="form-check"><input type="checkbox" name="snippet_pages[]" value="%s" id="page_%s" class="form-check-input" %s><label class="form-check-label" for="page_%s">%s</label></div>',
esc_attr($value),
esc_attr($value),
$checked,
esc_attr($value),
esc_html($label)
);
}
$html .= ' </div>';
$html .= ' </div>';
// Estado
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Estado</label>';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input type="checkbox" name="snippet_enabled" id="snippet_enabled" class="form-check-input" checked>';
$html .= ' <label class="form-check-label" for="snippet_enabled">Habilitado</label>';
$html .= ' </div>';
$html .= ' </div>';
// Código CSS
$html .= ' <div class="col-12">';
$html .= ' <label class="form-label">Código CSS *</label>';
$html .= ' <textarea name="snippet_css" id="snippet_css" class="form-control font-monospace" rows="10" required placeholder="/* Tu CSS aquí */"></textarea>';
$html .= ' <small class="text-muted">Crítico: máx 14KB | Diferido: máx 100KB</small>';
$html .= ' </div>';
// Botones
$html .= ' <div class="col-12">';
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">';
$html .= ' <i class="bi bi-check-circle me-1"></i> Guardar Cambios';
$html .= ' </button>';
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
$html .= ' </button>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </form>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Genera el JavaScript necesario para el formulario
*/
private function buildJavaScript(): string
{
return <<<JS
<script>
// Event delegation para botones de edición
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.btn-edit-snippet').forEach(function(btn) {
btn.addEventListener('click', function() {
const snippet = JSON.parse(this.dataset.snippet);
editCssSnippet(snippet);
});
});
});
function editCssSnippet(snippet) {
document.getElementById('snippet_id').value = snippet.id;
document.getElementById('snippet_name').value = snippet.name;
document.getElementById('snippet_description').value = snippet.description || '';
document.getElementById('snippet_type').value = snippet.type;
document.getElementById('snippet_order').value = snippet.order || 100;
document.getElementById('snippet_css').value = snippet.css;
document.getElementById('snippet_enabled').checked = snippet.enabled;
// Actualizar checkboxes de páginas
document.querySelectorAll('input[name="snippet_pages[]"]').forEach(cb => {
cb.checked = (snippet.pages || ['all']).includes(cb.value);
});
document.getElementById('snippet_name').focus();
// Scroll al formulario
document.getElementById('roi-snippet-form').scrollIntoView({ behavior: 'smooth' });
}
function resetCssForm() {
document.getElementById('roi-snippet-form').reset();
document.getElementById('snippet_id').value = '';
}
</script>
JS;
}
/**
* Opciones de páginas disponibles
*/
private function getPageOptions(): array
{
return [
'all' => 'Todas',
'home' => 'Inicio',
'posts' => 'Posts',
'pages' => 'Páginas',
'archives' => 'Archivos',
];
}
/**
* Obtiene mensaje flash de la URL
*/
private function getFlashMessage(): ?array
{
$message = $_GET['roi_message'] ?? null;
if ($message === 'success') {
return ['type' => 'success', 'text' => 'Cambios guardados correctamente'];
}
if ($message === 'error') {
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
return ['type' => 'error', 'text' => $error];
}
return null;
}
/**
* Genera script para mostrar Toast
*/
private function buildToastTrigger(array $message): string
{
$type = esc_js($message['type']);
$text = esc_js($message['text']);
// Mapear tipo a configuración de Bootstrap
$typeMap = [
'success' => ['bg' => 'success', 'icon' => 'bi-check-circle-fill'],
'error' => ['bg' => 'danger', 'icon' => 'bi-x-circle-fill'],
];
$config = $typeMap[$type] ?? $typeMap['success'];
$bg = $config['bg'];
$icon = $config['icon'];
return <<<HTML
<script>
document.addEventListener('DOMContentLoaded', function() {
// 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-{$bg} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi {$icon} me-2"></i>
<strong>{$text}</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();
});
// Limpiar parámetros de URL sin recargar
const url = new URL(window.location.href);
url.searchParams.delete('roi_message');
url.searchParams.delete('roi_error');
window.history.replaceState({}, '', url.toString());
});
</script>
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,60 @@
<?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'],
// Page Visibility (grupo especial _page_visibility)
'featuredImageVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'featuredImageVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'featuredImageVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'featuredImageVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'featuredImageExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'featuredImageExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'featuredImageExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'featuredImageExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,331 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
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>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'featuredImage');
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$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,88 @@
<?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'],
// Page Visibility (grupo especial _page_visibility)
'footerVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'footerVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'footerVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'footerVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'footerVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'footerExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'footerExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'footerExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'footerExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,470 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'footer');
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
{
$checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
$html = '<div class="form-check">';
$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;
}
}

View File

@@ -0,0 +1,78 @@
<?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'],
'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'heroVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'heroVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'heroVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'heroVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'heroExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'heroExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'heroExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'heroExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,480 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
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>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'hero');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroIsCritical" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroIsCritical">';
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$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,129 @@
<?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
);
// Script de toggle para exclusiones (Plan 99.11)
wp_enqueue_script(
'roi-exclusion-toggle',
$this->themeUri . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js',
['roi-admin-dashboard'],
filemtime(get_template_directory() . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.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,225 @@
<?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 ComponentGroupRegistry|null $groupRegistry Registro de grupos de componentes
* @param array<string, mixed> $components Componentes disponibles
*/
public function __construct(
private readonly ?GetComponentSettingsUseCase $getComponentSettingsUseCase = null,
private readonly ?ComponentGroupRegistry $groupRegistry = 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',
],
'archive-header' => [
'id' => 'archive-header',
'label' => 'Archive Header',
'icon' => 'bi-layout-text-window',
],
'post-grid' => [
'id' => 'post-grid',
'label' => 'Post Grid',
'icon' => 'bi-grid-3x3',
],
'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',
],
'adsense-placement' => [
'id' => 'adsense-placement',
'label' => 'AdSense',
'icon' => 'bi-megaphone',
],
'custom-css-manager' => [
'id' => 'custom-css-manager',
'label' => 'CSS Personalizado',
'icon' => 'bi-file-earmark-code',
],
];
}
/**
* 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
{
// Mapeo especial para componentes con acrónimos (CSS, API, etc.)
$specialMappings = [
'custom-css-manager' => 'CustomCSSManager',
];
// Usar mapeo especial si existe, sino convertir kebab-case a PascalCase
if (isset($specialMappings[$componentId])) {
$className = $specialMappings[$componentId];
} else {
// '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";
}
/**
* Obtiene los grupos de componentes
*
* @return array<string, array<string, mixed>>
*/
public function getComponentGroups(): array
{
if ($this->groupRegistry === null) {
return [];
}
return $this->groupRegistry->getGroups();
}
/**
* Obtiene el grupo al que pertenece un componente
*
* @param string $componentId ID del componente
* @return string|null ID del grupo o null
*/
public function getGroupForComponent(string $componentId): ?string
{
if ($this->groupRegistry === null) {
return null;
}
return $this->groupRegistry->getGroupForComponent($componentId);
}
}

View File

@@ -0,0 +1,630 @@
/**
* ROI Theme Admin Dashboard - Improved Version
* Basado en improved-panel.css del Design System
*/
/* ================================================
CSS VARIABLES - DESIGN SYSTEM
================================================ */
:root {
/* Navy Colors */
--roi-navy-dark: #0E2337;
--roi-navy-primary: #1e3a5f;
--roi-navy-light: #2c5282;
/* Orange Colors */
--roi-orange-primary: #FF8600;
--roi-orange-hover: #FF6B35;
--roi-orange-light: #FFB800;
/* Neutral Colors */
--roi-neutral-50: #f8f9fa;
--roi-neutral-100: #e9ecef;
--roi-neutral-600: #495057;
--roi-neutral-700: #6c757d;
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 12px 32px rgba(0, 0, 0, 0.15);
--shadow-orange: 0 6px 20px rgba(255, 134, 0, 0.15);
/* Transitions */
--transition-base: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
--transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ================================================
WORDPRESS ADMIN OVERRIDES
================================================ */
/* Sobrescribir max-width de .card de WordPress */
.wrap.roi-admin-panel .card {
max-width: none !important;
}
/* Fix para switches de Bootstrap */
.wrap.roi-admin-panel .form-switch .form-check-input {
all: unset !important;
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;
}
.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 (Legacy)
================================================ */
.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.83rem;
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);
}
}
/* ================================================
HEADER MEJORADO
================================================ */
.roi-home-header {
position: relative;
padding: 2.5rem 2rem;
background: var(--roi-navy-dark);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-lg);
border: none;
margin-bottom: 2.5rem;
}
/* Patrón de fondo sutil */
.roi-home-header__pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 134, 0, 0.05) 0%, transparent 50%),
radial-gradient(circle at 80% 50%, rgba(44, 82, 130, 0.08) 0%, transparent 50%);
pointer-events: none;
}
.roi-home-header__content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 1.5rem;
}
.roi-home-header__icon-wrapper {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--roi-orange-primary), var(--roi-orange-light));
border-radius: 16px;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(255, 134, 0, 0.3);
}
.roi-home-header__icon {
font-size: 2rem;
color: white;
}
.roi-home-header__text {
flex: 1;
}
.roi-home-header__title {
font-size: 1.8rem;
font-weight: 700;
color: white;
margin: 0 0 0.5rem 0;
line-height: 1.2;
}
.roi-home-header__subtitle {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
margin: 0;
font-weight: 400;
}
/* ================================================
GRID DE GRUPOS
================================================ */
.roi-groups-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
}
@media (max-width: 991px) {
.roi-groups-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
/* ================================================
GROUP CARDS MEJORADOS
================================================ */
.roi-group-card {
position: relative;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: var(--shadow-sm);
border: 1px solid var(--roi-neutral-100);
transition: var(--transition-base);
animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
}
/* Efecto glow en hover */
.roi-group-card__glow {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 12px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
box-shadow: 0 0 0 1px var(--roi-orange-primary);
}
.roi-group-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--roi-orange-primary);
}
.roi-group-card:hover .roi-group-card__glow {
opacity: 0.3;
}
/* Header del grupo */
.roi-group-card__header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 2px solid var(--roi-neutral-50);
}
.roi-group-card__icon-wrapper {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 134, 0, 0.1);
border-radius: 12px;
flex-shrink: 0;
transition: var(--transition-base);
}
.roi-group-card:hover .roi-group-card__icon-wrapper {
background: rgba(255, 134, 0, 0.15);
transform: scale(1.05);
}
.roi-group-card__icon {
font-size: 1.75rem;
color: var(--roi-orange-primary);
}
.roi-group-card__header-text {
flex: 1;
}
.roi-group-card__title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.35rem 0;
color: var(--roi-navy-primary);
line-height: 1.3;
}
.roi-group-card__description {
font-size: 0.9rem;
margin: 0;
color: var(--roi-neutral-700);
line-height: 1.5;
}
/* ================================================
COMPONENTS GRID
================================================ */
.roi-components-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
/* ================================================
MINI CARDS MEJORADOS
================================================ */
.roi-component-minicard {
position: relative;
background: white;
border: 1px solid var(--roi-neutral-100);
border-radius: 10px;
padding: 1.25rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: var(--transition-base);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
text-align: center;
overflow: hidden;
}
.roi-component-minicard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--roi-orange-primary), var(--roi-orange-light));
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
.roi-component-minicard:hover::before {
transform: scaleX(1);
}
.roi-component-minicard:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: var(--shadow-orange);
border-color: var(--roi-orange-primary);
}
.roi-component-minicard:active {
transform: translateY(-2px) scale(0.98);
}
.roi-component-minicard:focus {
outline: 2px solid var(--roi-orange-primary);
outline-offset: 3px;
}
/* Icono del mini card */
.roi-component-minicard__icon-bg {
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 134, 0, 0.08);
border-radius: 10px;
transition: var(--transition-base);
}
.roi-component-minicard:hover .roi-component-minicard__icon-bg {
background: rgba(255, 134, 0, 0.15);
transform: scale(1.1) rotate(5deg);
}
.roi-component-minicard__icon {
font-size: 1.5rem;
color: var(--roi-orange-primary);
}
.roi-component-minicard__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--roi-navy-dark);
line-height: 1.3;
letter-spacing: -0.01em;
}
.roi-component-minicard:hover .roi-component-minicard__label {
color: var(--roi-orange-primary);
}
/* ================================================
BREADCRUMB
================================================ */
.roi-breadcrumb {
padding: 1rem 1.5rem;
background: var(--roi-neutral-50);
border-radius: 8px;
border-left: 4px solid var(--roi-orange-primary);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.roi-breadcrumb__nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.roi-breadcrumb__separator {
color: var(--roi-neutral-700);
}
.roi-breadcrumb__group {
color: var(--roi-neutral-700);
font-size: 0.9rem;
}
.roi-breadcrumb__current {
font-size: 0.9rem;
font-weight: 600;
color: var(--roi-navy-primary);
}
/* Botón Volver */
.roi-back-to-home {
border-color: var(--roi-navy-primary);
color: var(--roi-navy-primary);
}
.roi-back-to-home:hover {
background-color: var(--roi-navy-primary);
border-color: var(--roi-navy-primary);
color: white;
}
/* ================================================
COMPONENT FORM CONTAINER
================================================ */
.roi-component-form-container {
animation: fadeIn 0.3s ease-in;
}
/* ================================================
ANIMATIONS
================================================ */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#roi-home-view,
#roi-component-view {
animation: fadeIn 0.4s ease-in;
}
/* ================================================
UTILITY CLASSES
================================================ */
.roi-hidden {
display: none !important;
}
/* ================================================
RESPONSIVE
================================================ */
@media (max-width: 991px) {
.roi-group-card {
padding: 1.5rem;
}
.roi-home-header {
padding: 2rem 1.5rem;
}
.roi-home-header__icon-wrapper {
width: 56px;
height: 56px;
}
.roi-home-header__icon {
font-size: 1.75rem;
}
.roi-home-header__title {
font-size: 1.5rem;
}
.nav-tabs-admin {
flex-wrap: wrap;
}
.nav-tabs-admin .nav-link {
font-size: 0.8rem;
padding: 0.35rem 0.5rem;
}
}
@media (max-width: 768px) {
.roi-home-header__content {
flex-direction: column;
text-align: center;
gap: 1rem;
}
.roi-components-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.roi-component-minicard {
padding: 1rem 0.75rem;
}
.roi-component-minicard__icon-bg {
width: 40px;
height: 40px;
}
.roi-component-minicard__icon {
font-size: 1.25rem;
}
.roi-group-card__icon-wrapper {
width: 44px;
height: 44px;
}
.roi-group-card__icon {
font-size: 1.5rem;
}
.nav-tabs-admin {
overflow-x: auto;
flex-wrap: nowrap;
}
.nav-tabs-admin .nav-item {
white-space: nowrap;
}
}
@media (max-width: 576px) {
.roi-groups-grid {
gap: 1rem;
}
.roi-home-header {
padding: 1.5rem 1rem;
}
.roi-home-header__title {
font-size: 1.3rem;
}
.roi-home-header__subtitle {
font-size: 0.875rem;
}
.roi-group-card {
padding: 1.25rem;
}
.roi-group-card__title {
font-size: 1.1rem;
}
.roi-components-grid {
grid-template-columns: 1fr;
}
.roi-component-minicard {
flex-direction: row;
text-align: left;
padding: 1rem;
}
}

View File

@@ -0,0 +1,521 @@
/**
* 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() {
// Nueva navegación por Cards/Grupos
initializeCardNavigation();
// Funcionalidad existente (solo si hay tabs visibles)
if (document.querySelector('.nav-tabs-admin')) {
initializeTabs();
}
initializeFormValidation();
initializeButtons();
initializeColorPickers();
});
/**
* Inicializa la navegación por Cards/Grupos (App-Style)
*/
function initializeCardNavigation() {
// Verificar que estamos en el panel correcto
const adminPanel = document.querySelector('.roi-admin-panel');
if (!adminPanel) {
return;
}
// Delegación de eventos para mini-cards
document.addEventListener('click', function(e) {
const minicard = e.target.closest('.roi-component-minicard');
if (minicard) {
e.preventDefault();
const componentId = minicard.getAttribute('data-component-id');
if (componentId) {
navigateToComponent(componentId);
}
}
});
// Botón volver al home
document.addEventListener('click', function(e) {
if (e.target.closest('.roi-back-to-home')) {
e.preventDefault();
navigateToHome();
}
});
}
/**
* Navega a un componente específico
*
* @param {string} componentId ID del componente en kebab-case
*/
function navigateToComponent(componentId) {
const url = new URL(window.location.href);
url.searchParams.set('component', componentId);
// Eliminar el parámetro admin-tab si existe (legacy)
url.searchParams.delete('admin-tab');
window.location.href = url.toString();
}
/**
* Navega de vuelta al home (vista de grupos)
*/
function navigateToHome() {
const url = new URL(window.location.href);
url.searchParams.delete('component');
url.searchParams.delete('admin-tab');
window.location.href = url.toString();
}
/**
* 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,104 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Infrastructure\Ui;
/**
* Registro de grupos de componentes para el admin panel
*
* Responsabilidad única: Gestionar la configuración de grupos
* y la asignación de componentes a grupos.
*
* @package ROITheme\Admin\Infrastructure\Ui
*/
final class ComponentGroupRegistry
{
/**
* Obtiene los grupos de componentes con sus configuraciones
*
* Los grupos son extensibles via filtro WordPress para permitir
* que plugins agreguen componentes a grupos existentes o creen nuevos.
*
* @return array<string, array<string, mixed>>
*/
public function getGroups(): array
{
// Design System: Todos los grupos usan el mismo gradiente Navy (#0E2337 → #1e3a5f)
// No se requiere propiedad 'color' ya que está definido en CSS
$defaultGroups = [
'header-navigation' => [
'label' => __('Header & Navegación', 'roi-theme'),
'icon' => 'bi-layout-text-window',
'description' => __('Barras superiores, menú y pie de página', 'roi-theme'),
'components' => ['top-notification-bar', 'navbar', 'footer']
],
'main-content' => [
'label' => __('Contenido Principal', 'roi-theme'),
'icon' => 'bi-file-richtext',
'description' => __('Secciones principales de páginas y posts', 'roi-theme'),
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post', 'archive-header', 'post-grid']
],
'ctas-conversion' => [
'label' => __('CTAs & Conversión', 'roi-theme'),
'icon' => 'bi-lightning-charge',
'description' => __('Llamadas a la acción y elementos de conversión', 'roi-theme'),
'components' => ['cta-lets-talk', 'cta-box-sidebar', 'cta-post']
],
'engagement' => [
'label' => __('Engagement', 'roi-theme'),
'icon' => 'bi-share',
'description' => __('Interacción social y compartir', 'roi-theme'),
'components' => ['social-share']
],
'forms' => [
'label' => __('Formularios', 'roi-theme'),
'icon' => 'bi-envelope-paper',
'description' => __('Formularios de contacto y captura', 'roi-theme'),
'components' => ['contact-form']
],
'settings' => [
'label' => __('Configuración', 'roi-theme'),
'icon' => 'bi-gear',
'description' => __('Ajustes globales del tema y monetización', 'roi-theme'),
'components' => ['theme-settings', 'adsense-placement', 'custom-css-manager']
],
];
/**
* Filtro para extender o modificar los grupos de componentes
*
* @param array<string, array<string, mixed>> $groups Grupos por defecto
* @return array<string, array<string, mixed>> Grupos modificados
*/
return apply_filters('roi_theme_component_groups', $defaultGroups);
}
/**
* Obtiene el grupo al que pertenece un componente
*
* @param string $componentId ID del componente en kebab-case
* @return string|null ID del grupo o null si no pertenece a ninguno
*/
public function getGroupForComponent(string $componentId): ?string
{
foreach ($this->getGroups() as $groupId => $group) {
if (in_array($componentId, $group['components'], true)) {
return $groupId;
}
}
return null;
}
/**
* Obtiene la información de un grupo específico
*
* @param string $groupId ID del grupo
* @return array<string, mixed>|null Datos del grupo o null
*/
public function getGroup(string $groupId): ?array
{
$groups = $this->getGroups();
return $groups[$groupId] ?? null;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* ROI Theme - Panel de Administración Principal
*
* Nueva UI con sistema de Cards/Grupos (App-Style Navigation)
*
* @var AdminDashboardRenderer $this
*/
declare(strict_types=1);
// Prevenir acceso directo
if (!defined('ABSPATH')) {
exit;
}
$components = $this->getComponents();
$groups = $this->getComponentGroups();
// =====================================================
// SANITIZACIÓN OBLIGATORIA según estándares WordPress
// =====================================================
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Solo lectura de parámetro para UI
$activeComponent = null;
if (isset($_GET['component'])) {
$requestedComponent = sanitize_text_field(wp_unslash($_GET['component']));
// Validar que el componente exista
if (array_key_exists($requestedComponent, $components)) {
$activeComponent = $requestedComponent;
}
}
?>
<div class="wrap roi-admin-panel">
<?php if ($activeComponent !== null): ?>
<!-- =====================================================
Vista de Componente Individual
===================================================== -->
<?php include __DIR__ . '/partials/component-view.php'; ?>
<?php else: ?>
<!-- =====================================================
Vista Home: Grupos y Cards
===================================================== -->
<?php include __DIR__ . '/partials/groups-home.php'; ?>
<?php endif; ?>
</div><!-- /wrap -->

View File

@@ -0,0 +1,48 @@
<?php
/**
* Breadcrumb de navegación
*
* @var AdminDashboardRenderer $this
* @var string $activeComponent ID del componente activo
* @var array<string, array<string, mixed>> $groups Grupos de componentes
* @var array<string, array<string, string>> $components Componentes disponibles
* @var array<string, mixed>|null $group Grupo del componente activo
* @var array<string, string>|null $component Datos del componente activo
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
?>
<nav class="roi-breadcrumb mb-4" aria-label="<?php echo esc_attr__('Navegación', 'roi-theme'); ?>">
<div class="d-flex align-items-center flex-wrap gap-2">
<!-- Botón Volver -->
<button type="button" class="roi-back-to-home btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>
<?php echo esc_html__('Volver', 'roi-theme'); ?>
</button>
<!-- Separador -->
<span class="roi-breadcrumb__separator text-muted">/</span>
<!-- Grupo -->
<?php if ($group): ?>
<span class="roi-breadcrumb__group">
<i class="bi <?php echo esc_attr($group['icon']); ?> me-1" style="color: <?php echo esc_attr($group['color']); ?>;"></i>
<?php echo esc_html($group['label']); ?>
</span>
<span class="roi-breadcrumb__separator text-muted">/</span>
<?php endif; ?>
<!-- Componente actual -->
<?php if ($component): ?>
<span class="roi-breadcrumb__current fw-semibold" aria-current="page" style="color: #FF8600;">
<i class="bi <?php echo esc_attr($component['icon']); ?> me-1"></i>
<?php echo esc_html($component['label']); ?>
</span>
<?php endif; ?>
</div>
</nav>

View File

@@ -0,0 +1,81 @@
<?php
/**
* Vista de Componente Individual con Breadcrumb
*
* @var AdminDashboardRenderer $this
* @var string $activeComponent ID del componente activo
* @var array<string, array<string, mixed>> $groups Grupos de componentes
* @var array<string, array<string, string>> $components Componentes disponibles
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
$component = $components[$activeComponent] ?? null;
$groupId = $this->getGroupForComponent($activeComponent);
$group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
?>
<div id="roi-component-view">
<!-- Breadcrumb Navigation -->
<?php include __DIR__ . '/breadcrumb.php'; ?>
<!-- Component Form Container -->
<!-- IMPORTANTE: El tab-pane con clase .active es necesario para que el JS
de handleSaveSettings() pueda encontrar los campos del formulario -->
<div class="tab-content">
<div class="tab-pane fade show active"
id="<?php echo esc_attr($activeComponent); ?>Tab"
role="tabpanel">
<div class="roi-component-form-container">
<?php
// Renderizar FormBuilder del componente
$formBuilderClass = $this->getFormBuilderClass($activeComponent);
if (class_exists($formBuilderClass)) {
$formBuilder = new $formBuilderClass($this);
echo $formBuilder->buildForm($activeComponent);
} else {
?>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<?php
echo esc_html(
sprintf(
/* translators: %s: FormBuilder class name */
__('FormBuilder no encontrado: %s', 'roi-theme'),
$formBuilderClass
)
);
?>
</div>
<?php
}
?>
</div>
</div>
</div>
<?php
// Componentes con sistema de guardado propio (CRUD de entidades)
$componentsWithOwnSaveSystem = ['custom-css-manager'];
if (!in_array($activeComponent, $componentsWithOwnSaveSystem, true)):
?>
<!-- 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>
<?php echo esc_html__('Cancelar', 'roi-theme'); ?>
</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>
<?php echo esc_html__('Guardar Cambios', 'roi-theme'); ?>
</button>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,92 @@
<?php
/**
* Vista Home: Grupos de componentes con mini-cards (Improved Version)
*
* @var AdminDashboardRenderer $this
* @var array<string, array<string, mixed>> $groups Grupos de componentes
* @var array<string, array<string, string>> $components Componentes disponibles
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
?>
<div id="roi-home-view">
<!-- Header Mejorado -->
<div class="roi-home-header">
<div class="roi-home-header__pattern"></div>
<div class="roi-home-header__content">
<div class="roi-home-header__icon-wrapper">
<i class="bi bi-grid-3x3-gap-fill roi-home-header__icon"></i>
</div>
<div class="roi-home-header__text">
<h1 class="roi-home-header__title">
<?php echo esc_html__('Panel de Administración ROI Theme', 'roi-theme'); ?>
</h1>
<p class="roi-home-header__subtitle">
<?php echo esc_html__('Selecciona un componente para configurarlo y personalizarlo', 'roi-theme'); ?>
</p>
</div>
</div>
</div>
<!-- Grid de Grupos Mejorado -->
<div class="roi-groups-grid">
<?php
$delay = 0;
foreach ($groups as $groupId => $group):
?>
<div class="roi-group-card"
data-group-id="<?php echo esc_attr($groupId); ?>"
style="animation-delay: <?php echo esc_attr($delay . 's'); ?>">
<div class="roi-group-card__glow"></div>
<div class="roi-group-card__header">
<div class="roi-group-card__icon-wrapper">
<i class="bi <?php echo esc_attr($group['icon']); ?> roi-group-card__icon"></i>
</div>
<div class="roi-group-card__header-text">
<h3 class="roi-group-card__title">
<?php echo esc_html($group['label']); ?>
</h3>
<p class="roi-group-card__description">
<?php echo esc_html($group['description']); ?>
</p>
</div>
</div>
<div class="roi-components-grid">
<?php foreach ($group['components'] as $componentId): ?>
<?php if (isset($components[$componentId])): ?>
<?php $component = $components[$componentId]; ?>
<button type="button"
class="roi-component-minicard"
data-component-id="<?php echo esc_attr($componentId); ?>"
aria-label="<?php echo esc_attr(
sprintf(
/* translators: %s: component label */
__('Configurar %s', 'roi-theme'),
$component['label']
)
); ?>">
<div class="roi-component-minicard__icon-bg">
<i class="bi <?php echo esc_attr($component['icon']); ?> roi-component-minicard__icon"></i>
</div>
<span class="roi-component-minicard__label">
<?php echo esc_html($component['label']); ?>
</span>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php
$delay += 0.1;
endforeach;
?>
</div>
</div>

View File

@@ -0,0 +1,91 @@
<?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'],
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'],
'navbarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'navbarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'navbarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'navbarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'navbarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'navbarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'navbarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'navbarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'navbarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'navbarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,582 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
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>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'navbar');
// Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
$html .= ' <div class="mb-2">';
$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>';
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarIsCritical" name="visibility[is_critical]" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarIsCritical">';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$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,98 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\PostGrid\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para Post Grid
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*/
final class PostGridFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'post-grid';
}
public function getFieldMapping(): array
{
return [
// Visibility
'postGridEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'postGridShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'postGridShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Page Visibility (grupo especial _page_visibility)
'postGridVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'postGridVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'postGridVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'postGridVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'postGridVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions)
'postGridExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'postGridExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'postGridExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'postGridExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'postGridShowThumbnail' => ['group' => 'content', 'attribute' => 'show_thumbnail'],
'postGridShowExcerpt' => ['group' => 'content', 'attribute' => 'show_excerpt'],
'postGridShowMeta' => ['group' => 'content', 'attribute' => 'show_meta'],
'postGridShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'postGridExcerptLength' => ['group' => 'content', 'attribute' => 'excerpt_length'],
'postGridReadMoreText' => ['group' => 'content', 'attribute' => 'read_more_text'],
'postGridNoPostsMessage' => ['group' => 'content', 'attribute' => 'no_posts_message'],
// Layout
'postGridColumnsDesktop' => ['group' => 'layout', 'attribute' => 'columns_desktop'],
'postGridColumnsTablet' => ['group' => 'layout', 'attribute' => 'columns_tablet'],
'postGridColumnsMobile' => ['group' => 'layout', 'attribute' => 'columns_mobile'],
'postGridImagePosition' => ['group' => 'layout', 'attribute' => 'image_position'],
// Media
'postGridFallbackImage' => ['group' => 'media', 'attribute' => 'fallback_image'],
'postGridFallbackImageAlt' => ['group' => 'media', 'attribute' => 'fallback_image_alt'],
// Typography
'postGridHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
'postGridCardTitleSize' => ['group' => 'typography', 'attribute' => 'card_title_size'],
'postGridCardTitleWeight' => ['group' => 'typography', 'attribute' => 'card_title_weight'],
'postGridExcerptSize' => ['group' => 'typography', 'attribute' => 'excerpt_size'],
'postGridMetaSize' => ['group' => 'typography', 'attribute' => 'meta_size'],
// Colors
'postGridCardBgColor' => ['group' => 'colors', 'attribute' => 'card_bg_color'],
'postGridCardTitleColor' => ['group' => 'colors', 'attribute' => 'card_title_color'],
'postGridCardHoverBgColor' => ['group' => 'colors', 'attribute' => 'card_hover_bg_color'],
'postGridCardBorderColor' => ['group' => 'colors', 'attribute' => 'card_border_color'],
'postGridCardHoverBorderColor' => ['group' => 'colors', 'attribute' => 'card_hover_border_color'],
'postGridExcerptColor' => ['group' => 'colors', 'attribute' => 'excerpt_color'],
'postGridMetaColor' => ['group' => 'colors', 'attribute' => 'meta_color'],
'postGridCategoryBgColor' => ['group' => 'colors', 'attribute' => 'category_bg_color'],
'postGridCategoryTextColor' => ['group' => 'colors', 'attribute' => 'category_text_color'],
'postGridPaginationColor' => ['group' => 'colors', 'attribute' => 'pagination_color'],
'postGridPaginationActiveBg' => ['group' => 'colors', 'attribute' => 'pagination_active_bg'],
'postGridPaginationActiveColor' => ['group' => 'colors', 'attribute' => 'pagination_active_color'],
// Spacing
'postGridGapHorizontal' => ['group' => 'spacing', 'attribute' => 'gap_horizontal'],
'postGridGapVertical' => ['group' => 'spacing', 'attribute' => 'gap_vertical'],
'postGridCardPadding' => ['group' => 'spacing', 'attribute' => 'card_padding'],
'postGridSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
'postGridSectionMarginBottom' => ['group' => 'spacing', 'attribute' => 'section_margin_bottom'],
// Visual Effects
'postGridCardBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'card_border_radius'],
'postGridCardShadow' => ['group' => 'visual_effects', 'attribute' => 'card_shadow'],
'postGridCardHoverShadow' => ['group' => 'visual_effects', 'attribute' => 'card_hover_shadow'],
'postGridCardTransition' => ['group' => 'visual_effects', 'attribute' => 'card_transition'],
'postGridImageBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'image_border_radius'],
];
}
}

View File

@@ -0,0 +1,781 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\PostGrid\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* PostGridFormBuilder - Genera formulario admin para Post Grid
*
* Sigue el mismo patron visual que RelatedPostFormBuilder:
* - Header con gradiente navy
* - Layout de 2 columnas
* - Cards con borde izquierdo
* - Inputs compactos (form-control-sm)
*
* @package ROITheme\Admin\PostGrid\Infrastructure\Ui
*/
final class PostGridFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->buildHeader();
$html .= '<div class="row g-3">';
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildShortcodeGuide();
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildMediaGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildLayoutGroup($componentId);
$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
{
$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 Post Grid';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Grid de posts para listados, archivos y resultados de busqueda';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="post-grid">';
$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('postGridEnabled', 'Activar componente', 'bi-power', $enabled);
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
$html .= $this->buildSwitch('postGridShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('postGridShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// Checkboxes de visibilidad por tipo de página
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', false);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// Reglas de exclusion
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'postGrid');
$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>';
// Switches de contenido
$showThumbnail = $this->renderer->getFieldValue($componentId, 'content', 'show_thumbnail', true);
$html .= $this->buildSwitch('postGridShowThumbnail', 'Mostrar imagen destacada', 'bi-image', $showThumbnail);
$showExcerpt = $this->renderer->getFieldValue($componentId, 'content', 'show_excerpt', true);
$html .= $this->buildSwitch('postGridShowExcerpt', 'Mostrar extracto', 'bi-text-paragraph', $showExcerpt);
$showMeta = $this->renderer->getFieldValue($componentId, 'content', 'show_meta', true);
$html .= $this->buildSwitch('postGridShowMeta', 'Mostrar metadatos', 'bi-info-circle', $showMeta);
$showCategories = $this->renderer->getFieldValue($componentId, 'content', 'show_categories', true);
$html .= $this->buildSwitch('postGridShowCategories', 'Mostrar categorias', 'bi-folder', $showCategories);
$html .= ' <hr class="my-3">';
// Excerpt length
$excerptLength = $this->renderer->getFieldValue($componentId, 'content', 'excerpt_length', '20');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridExcerptLength" class="form-label small mb-1 fw-semibold">Longitud del extracto</label>';
$html .= ' <select id="postGridExcerptLength" class="form-select form-select-sm">';
$html .= ' <option value="10"' . ($excerptLength === '10' ? ' selected' : '') . '>10 palabras</option>';
$html .= ' <option value="15"' . ($excerptLength === '15' ? ' selected' : '') . '>15 palabras</option>';
$html .= ' <option value="20"' . ($excerptLength === '20' ? ' selected' : '') . '>20 palabras</option>';
$html .= ' <option value="25"' . ($excerptLength === '25' ? ' selected' : '') . '>25 palabras</option>';
$html .= ' <option value="30"' . ($excerptLength === '30' ? ' selected' : '') . '>30 palabras</option>';
$html .= ' </select>';
$html .= ' </div>';
// Read more text
$readMoreText = $this->renderer->getFieldValue($componentId, 'content', 'read_more_text', 'Leer mas');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridReadMoreText" class="form-label small mb-1 fw-semibold">Texto de leer mas</label>';
$html .= ' <input type="text" id="postGridReadMoreText" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($readMoreText) . '">';
$html .= ' </div>';
// No posts message
$noPostsMessage = $this->renderer->getFieldValue($componentId, 'content', 'no_posts_message', 'No se encontraron publicaciones');
$html .= ' <div class="mb-0">';
$html .= ' <label for="postGridNoPostsMessage" class="form-label small mb-1 fw-semibold">Mensaje sin posts</label>';
$html .= ' <input type="text" id="postGridNoPostsMessage" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($noPostsMessage) . '">';
$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-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="postGridColumnsDesktop" 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="postGridColumnsDesktop" 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="postGridColumnsTablet" 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="postGridColumnsTablet" 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-3">';
$html .= ' <label for="postGridColumnsMobile" 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="postGridColumnsMobile" 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>';
// Image position
$imagePosition = $this->renderer->getFieldValue($componentId, 'layout', 'image_position', 'top');
$html .= ' <div class="mb-0">';
$html .= ' <label for="postGridImagePosition" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-aspect-ratio me-1" style="color: #FF8600;"></i>';
$html .= ' Posicion de imagen';
$html .= ' </label>';
$html .= ' <select id="postGridImagePosition" class="form-select form-select-sm">';
$html .= ' <option value="top"' . ($imagePosition === 'top' ? ' selected' : '') . '>Arriba</option>';
$html .= ' <option value="left"' . ($imagePosition === 'left' ? ' selected' : '') . '>Izquierda</option>';
$html .= ' <option value="none"' . ($imagePosition === 'none' ? ' selected' : '') . '>Sin imagen</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 .= ' Medios';
$html .= ' </h5>';
// Fallback image
$fallbackImage = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image', '');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridFallbackImage" class="form-label small mb-1 fw-semibold">URL imagen por defecto</label>';
$html .= ' <input type="url" id="postGridFallbackImage" class="form-control form-control-sm" ';
$html .= ' value="' . esc_url($fallbackImage) . '" placeholder="https://...">';
$html .= ' </div>';
// Fallback image alt
$fallbackImageAlt = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image_alt', 'Imagen por defecto');
$html .= ' <div class="mb-0">';
$html .= ' <label for="postGridFallbackImageAlt" class="form-label small mb-1 fw-semibold">Texto alternativo</label>';
$html .= ' <input type="text" id="postGridFallbackImageAlt" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($fallbackImageAlt) . '">';
$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>';
// Heading level
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h3');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
$html .= ' <select id="postGridHeadingLevel" class="form-select form-select-sm">';
$html .= ' <option value="h2"' . ($headingLevel === 'h2' ? ' selected' : '') . '>H2</option>';
$html .= ' <option value="h3"' . ($headingLevel === 'h3' ? ' selected' : '') . '>H3</option>';
$html .= ' <option value="h4"' . ($headingLevel === 'h4' ? ' selected' : '') . '>H4</option>';
$html .= ' <option value="h5"' . ($headingLevel === 'h5' ? ' selected' : '') . '>H5</option>';
$html .= ' <option value="h6"' . ($headingLevel === 'h6' ? ' selected' : '') . '>H6</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardTitleSize = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_size', '1.1rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
$html .= ' <input type="text" id="postGridCardTitleSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTitleSize) . '">';
$html .= ' </div>';
$cardTitleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_weight', '600');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
$html .= ' <input type="text" id="postGridCardTitleWeight" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTitleWeight) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$excerptSize = $this->renderer->getFieldValue($componentId, 'typography', 'excerpt_size', '0.9rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridExcerptSize" class="form-label small mb-1 fw-semibold">Tamano extracto</label>';
$html .= ' <input type="text" id="postGridExcerptSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($excerptSize) . '">';
$html .= ' </div>';
$metaSize = $this->renderer->getFieldValue($componentId, 'typography', 'meta_size', '0.8rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridMetaSize" class="form-label small mb-1 fw-semibold">Tamano metadatos</label>';
$html .= ' <input type="text" id="postGridMetaSize" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($metaSize) . '">';
$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>';
// 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('postGridCardBgColor', 'Fondo', $cardBgColor);
$cardTitleColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_title_color', '#0E2337');
$html .= $this->buildColorPicker('postGridCardTitleColor', 'Titulo', $cardTitleColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardHoverBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_bg_color', '#f9fafb');
$html .= $this->buildColorPicker('postGridCardHoverBgColor', 'Fondo hover', $cardHoverBgColor);
$cardBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_border_color', '#e5e7eb');
$html .= $this->buildColorPicker('postGridCardBorderColor', 'Borde', $cardBorderColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$cardHoverBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_border_color', '#FF8600');
$html .= $this->buildColorPicker('postGridCardHoverBorderColor', 'Borde hover', $cardHoverBorderColor);
$excerptColor = $this->renderer->getFieldValue($componentId, 'colors', 'excerpt_color', '#6b7280');
$html .= $this->buildColorPicker('postGridExcerptColor', 'Extracto', $excerptColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$metaColor = $this->renderer->getFieldValue($componentId, 'colors', 'meta_color', '#9ca3af');
$html .= $this->buildColorPicker('postGridMetaColor', 'Metadatos', $metaColor);
$categoryBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_bg_color', '#FFF5EB');
$html .= $this->buildColorPicker('postGridCategoryBgColor', 'Fondo cat.', $categoryBgColor);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-3">';
$categoryTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_text_color', '#FF8600');
$html .= $this->buildColorPicker('postGridCategoryTextColor', 'Texto cat.', $categoryTextColor);
$html .= ' </div>';
// Paginacion
$html .= ' <p class="small fw-semibold mb-2">Paginacion</p>';
$html .= ' <div class="row g-2 mb-3">';
$paginationColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_color', '#0E2337');
$html .= $this->buildColorPicker('postGridPaginationColor', 'Color', $paginationColor);
$paginationActiveBg = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_bg', '#FF8600');
$html .= $this->buildColorPicker('postGridPaginationActiveBg', 'Activo fondo', $paginationActiveBg);
$html .= ' </div>';
$html .= ' <div class="row g-2 mb-0">';
$paginationActiveColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_color', '#ffffff');
$html .= $this->buildColorPicker('postGridPaginationActiveColor', 'Activo texto', $paginationActiveColor);
$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>';
// Separación entre cards
$html .= ' <p class="small text-muted mb-2">Separacion entre cards:</p>';
$html .= ' <div class="row g-2 mb-3">';
// Gap horizontal (entre columnas)
$gapHorizontal = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_horizontal', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridGapHorizontal" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>Horizontal';
$html .= ' </label>';
$html .= ' <select id="postGridGapHorizontal" class="form-select form-select-sm">';
$gapOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px', '40px', '48px'];
foreach ($gapOptions as $opt) {
$selected = ($gapHorizontal === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
// Gap vertical (entre filas)
$gapVertical = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_vertical', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridGapVertical" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-collapse me-1" style="color: #FF8600;"></i>Vertical';
$html .= ' </label>';
$html .= ' <select id="postGridGapVertical" class="form-select form-select-sm">';
foreach ($gapOptions as $opt) {
$selected = ($gapVertical === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
// Padding interno de cada card
$html .= ' <p class="small text-muted mb-2">Padding interno de card:</p>';
$html .= ' <div class="row g-2 mb-3">';
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '20px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-box me-1" style="color: #FF8600;"></i>Padding';
$html .= ' </label>';
$html .= ' <select id="postGridCardPadding" class="form-select form-select-sm">';
$paddingOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px'];
foreach ($paddingOptions as $opt) {
$selected = ($cardPadding === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' <div class="col-6"></div>';
$html .= ' </div>';
// Margenes de la seccion
$html .= ' <p class="small text-muted mb-2">Margenes de la seccion:</p>';
$html .= ' <div class="row g-2 mb-0">';
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrow-up me-1" style="color: #FF8600;"></i>Arriba';
$html .= ' </label>';
$html .= ' <select id="postGridSectionMarginTop" class="form-select form-select-sm">';
$marginOptions = ['0px', '8px', '16px', '24px', '32px', '48px', '64px'];
foreach ($marginOptions as $opt) {
$selected = ($sectionMarginTop === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '32px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrow-down me-1" style="color: #FF8600;"></i>Abajo';
$html .= ' </label>';
$html .= ' <select id="postGridSectionMarginBottom" class="form-select form-select-sm">';
foreach ($marginOptions as $opt) {
$selected = ($sectionMarginBottom === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$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.5rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardBorderRadius" class="form-label small mb-1 fw-semibold">Radio borde</label>';
$html .= ' <input type="text" id="postGridCardBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardBorderRadius) . '">';
$html .= ' </div>';
$imageBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'image_border_radius', '0.375rem');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridImageBorderRadius" class="form-label small mb-1 fw-semibold">Radio imagen</label>';
$html .= ' <input type="text" id="postGridImageBorderRadius" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($imageBorderRadius) . '">';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="mb-3">';
$cardTransition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_transition', 'all 0.3s ease');
$html .= ' <label for="postGridCardTransition" class="form-label small mb-1 fw-semibold">Transicion</label>';
$html .= ' <input type="text" id="postGridCardTransition" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardTransition) . '">';
$html .= ' </div>';
$html .= ' <div class="mb-3">';
$cardShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_shadow', '0 1px 3px rgba(0,0,0,0.1)');
$html .= ' <label for="postGridCardShadow" class="form-label small mb-1 fw-semibold">Sombra normal</label>';
$html .= ' <input type="text" id="postGridCardShadow" 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 4px 12px rgba(0,0,0,0.15)');
$html .= ' <label for="postGridCardHoverShadow" class="form-label small mb-1 fw-semibold">Sombra hover</label>';
$html .= ' <input type="text" id="postGridCardHoverShadow" 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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
private function buildShortcodeGuide(): 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-code-square me-2" style="color: #FF8600;"></i>';
$html .= ' Shortcode [roi_post_grid]';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">';
$html .= ' Usa este shortcode para insertar grids de posts en cualquier pagina o entrada. ';
$html .= ' Los estilos se heredan de la configuracion de este componente.';
$html .= ' </p>';
// Uso basico
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-1-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Uso basico (9 posts, 3 columnas)';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid]</code>';
$html .= ' </div>';
// Por categoria
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-2-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Filtrar por categoria';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid category="precios-unitarios"]</code>';
$html .= ' </div>';
// Personalizar cantidad y columnas
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-3-circle me-1" style="color: #FF8600;"></i>';
$html .= ' 6 posts en 2 columnas';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="6" columns="2"]</code>';
$html .= ' </div>';
// Con paginacion
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-4-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Con paginacion';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="12" show_pagination="true"]</code>';
$html .= ' </div>';
// Filtrar por tag
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-5-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Filtrar por etiqueta';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid tag="tutorial"]</code>';
$html .= ' </div>';
// Ejemplo completo
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-6-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Ejemplo completo';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.75rem;">';
$html .= ' <code class="text-warning">[roi_post_grid category="cursos" posts_per_page="6" columns="3" show_meta="false" show_categories="true"]</code>';
$html .= ' </div>';
// Tabla de atributos
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-list-check me-1" style="color: #FF8600;"></i>';
$html .= ' Atributos disponibles';
$html .= ' </p>';
$html .= ' <div class="table-responsive">';
$html .= ' <table class="table table-sm table-bordered small mb-0">';
$html .= ' <thead class="table-light">';
$html .= ' <tr><th>Atributo</th><th>Default</th><th>Descripcion</th></tr>';
$html .= ' </thead>';
$html .= ' <tbody>';
$html .= ' <tr><td><code>posts_per_page</code></td><td>9</td><td>Cantidad de posts</td></tr>';
$html .= ' <tr><td><code>columns</code></td><td>3</td><td>Columnas (1-4)</td></tr>';
$html .= ' <tr><td><code>category</code></td><td>-</td><td>Slug de categoria</td></tr>';
$html .= ' <tr><td><code>exclude_category</code></td><td>-</td><td>Excluir categoria</td></tr>';
$html .= ' <tr><td><code>tag</code></td><td>-</td><td>Slug de etiqueta</td></tr>';
$html .= ' <tr><td><code>author</code></td><td>-</td><td>ID o username</td></tr>';
$html .= ' <tr><td><code>orderby</code></td><td>date</td><td>date, title, rand</td></tr>';
$html .= ' <tr><td><code>order</code></td><td>DESC</td><td>DESC o ASC</td></tr>';
$html .= ' <tr><td><code>show_pagination</code></td><td>false</td><td>Mostrar paginacion</td></tr>';
$html .= ' <tr><td><code>show_thumbnail</code></td><td>true</td><td>Mostrar imagen</td></tr>';
$html .= ' <tr><td><code>show_excerpt</code></td><td>true</td><td>Mostrar extracto</td></tr>';
$html .= ' <tr><td><code>show_meta</code></td><td>true</td><td>Fecha y autor</td></tr>';
$html .= ' <tr><td><code>show_categories</code></td><td>true</td><td>Badges categoria</td></tr>';
$html .= ' <tr><td><code>excerpt_length</code></td><td>20</td><td>Palabras extracto</td></tr>';
$html .= ' <tr><td><code>exclude_posts</code></td><td>-</td><td>IDs separados por coma</td></tr>';
$html .= ' <tr><td><code>offset</code></td><td>0</td><td>Saltar N posts</td></tr>';
$html .= ' <tr><td><code>id</code></td><td>-</td><td>ID unico (multiples grids)</td></tr>';
$html .= ' <tr><td><code>class</code></td><td>-</td><td>Clase CSS adicional</td></tr>';
$html .= ' </tbody>';
$html .= ' </table>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,89 @@
<?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'],
// Page Visibility (grupo especial _page_visibility)
'relatedPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'relatedPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'relatedPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'relatedPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'relatedPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'relatedPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'relatedPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'relatedPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'relatedPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,552 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'relatedPost');
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$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,158 @@
<?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;
use ROITheme\Admin\Shared\Infrastructure\Services\ExclusionFieldProcessor;
/**
* 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
*
* Soporta tipos especiales para campos de exclusion:
* - json_array: Convierte "a, b, c" a ["a", "b", "c"]
* - json_array_int: Convierte "1, 2, 3" a [1, 2, 3]
* - json_array_lines: Convierte lineas a array
*/
private function mapSettings(array $settings, array $fieldMapping): array
{
$mappedSettings = [];
$fieldProcessor = new ExclusionFieldProcessor();
foreach ($settings as $fieldId => $value) {
if (!isset($fieldMapping[$fieldId])) {
continue;
}
$mapping = $fieldMapping[$fieldId];
$groupName = $mapping['group'];
$attributeName = $mapping['attribute'];
$type = $mapping['type'] ?? null;
if (!isset($mappedSettings[$groupName])) {
$mappedSettings[$groupName] = [];
}
// Procesar valor segun tipo
if ($type !== null && is_string($value)) {
$value = $fieldProcessor->process($value, $type);
}
$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,72 @@
<?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',
'AdsensePlacement',
'ArchiveHeader',
'PostGrid',
];
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,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Services;
/**
* Servicio para procesar campos de exclusion antes de guardar en BD
*
* Convierte formatos de UI a JSON para almacenamiento.
*
* v1.1: Extraido de AdminAjaxHandler (SRP)
*
* @package ROITheme\Admin\Shared\Infrastructure\Services
*/
final class ExclusionFieldProcessor
{
/**
* Procesa un valor de campo de exclusion segun su tipo
*
* @param string $value Valor del campo (desde UI)
* @param string $type Tipo de campo: json_array, json_array_int, json_array_lines
* @return string JSON string para almacenar en BD
*/
public function process(string $value, string $type): string
{
return match ($type) {
'json_array' => $this->processJsonArray($value),
'json_array_int' => $this->processJsonArrayInt($value),
'json_array_lines' => $this->processJsonArrayLines($value),
default => $value,
};
}
/**
* "a, b, c" -> ["a", "b", "c"]
*/
private function processJsonArray(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_UNICODE);
}
/**
* "1, 2, 3" -> [1, 2, 3]
*/
private function processJsonArrayInt(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, 'is_numeric');
$items = array_map('intval', $items);
return json_encode(array_values($items));
}
/**
* Lineas separadas -> array
*/
private function processJsonArrayLines(string $value): string
{
$items = preg_split('/\r\n|\r|\n/', $value);
$items = array_map('trim', $items);
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}

View File

@@ -0,0 +1,31 @@
/**
* Toggle para mostrar/ocultar reglas de exclusion en FormBuilders
*
* Escucha cambios en checkboxes con ID que termine en "ExclusionsEnabled"
* y muestra/oculta el contenedor de reglas correspondiente.
*
* @package ROITheme\Admin
*/
(function() {
'use strict';
function initExclusionToggles() {
document.querySelectorAll('[id$="ExclusionsEnabled"]').forEach(function(checkbox) {
// Handler para cambios
checkbox.addEventListener('change', function() {
const prefix = this.id.replace('ExclusionsEnabled', '');
const rulesContainer = document.getElementById(prefix + 'ExclusionRules');
if (rulesContainer) {
rulesContainer.style.display = this.checked ? 'block' : 'none';
}
});
});
}
// Inicializar cuando DOM este listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initExclusionToggles);
} else {
initExclusionToggles();
}
})();

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* Componente UI parcial reutilizable para reglas de exclusion
*
* Genera el HTML para la seccion de exclusiones en FormBuilders.
* Debe ser incluido despues de la seccion de visibilidad por tipo de pagina.
*
* Uso en FormBuilder:
* ```php
* $exclusionPartial = new ExclusionFormPartial($this->renderer);
* $html .= $exclusionPartial->render($componentId, 'prefijo');
* ```
*
* @package ROITheme\Admin\Shared\Infrastructure\Ui
*/
final class ExclusionFormPartial
{
private const GROUP_NAME = '_exclusions';
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {}
/**
* Renderiza la seccion de exclusiones
*
* @param string $componentId ID del componente (kebab-case)
* @param string $prefix Prefijo para IDs de campos (ej: 'cta' genera 'ctaExclusionsEnabled')
* @return string HTML de la seccion
*/
public function render(string $componentId, string $prefix): string
{
$html = '';
$html .= $this->buildExclusionHeader();
$html .= $this->buildExclusionToggle($componentId, $prefix);
$html .= $this->buildExclusionRules($componentId, $prefix);
return $html;
}
private function buildExclusionHeader(): string
{
$html = '<hr class="my-3">';
$html .= '<p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-funnel me-1" style="color: #FF8600;"></i>';
$html .= ' Reglas de exclusion avanzadas';
$html .= '</p>';
$html .= '<p class="small text-muted mb-2">';
$html .= ' Excluir este componente de categorias, posts o URLs especificos.';
$html .= '</p>';
return $html;
}
private function buildExclusionToggle(string $componentId, string $prefix): string
{
$enabled = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclusions_enabled',
false
);
$checked = $this->toBool($enabled);
$id = $prefix . 'ExclusionsEnabled';
$html = '<div class="mb-3">';
$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 .= ' <i class="bi bi-filter-circle me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Activar reglas de exclusion</strong>';
$html .= ' </label>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildExclusionRules(string $componentId, string $prefix): string
{
$enabled = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclusions_enabled',
false
);
$display = $this->toBool($enabled) ? 'block' : 'none';
$html = sprintf(
'<div id="%sExclusionRules" style="display: %s;">',
esc_attr($prefix),
$display
);
$html .= $this->buildCategoryField($componentId, $prefix);
$html .= $this->buildPostIdsField($componentId, $prefix);
$html .= $this->buildUrlPatternsField($componentId, $prefix);
$html .= '</div>';
return $html;
}
private function buildCategoryField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_categories',
'[]'
);
$categories = $this->jsonToCommaList($value);
$id = $prefix . 'ExcludeCategories';
$html = '<div class="mb-3">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-folder me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir en categorias';
$html .= ' </label>';
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="noticias, eventos, tutoriales">',
esc_attr($id),
esc_attr($categories)
);
$html .= ' <small class="text-muted">Slugs de categorias separados por comas</small>';
$html .= '</div>';
return $html;
}
private function buildPostIdsField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_post_ids',
'[]'
);
$postIds = $this->jsonToCommaList($value);
$id = $prefix . 'ExcludePostIds';
$html = '<div class="mb-3">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-hash me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir en posts/paginas';
$html .= ' </label>';
$html .= sprintf(
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="123, 456, 789">',
esc_attr($id),
esc_attr($postIds)
);
$html .= ' <small class="text-muted">IDs de posts o paginas separados por comas</small>';
$html .= '</div>';
return $html;
}
private function buildUrlPatternsField(string $componentId, string $prefix): string
{
$value = $this->renderer->getFieldValue(
$componentId,
self::GROUP_NAME,
'exclude_url_patterns',
'[]'
);
$patterns = $this->jsonToLineList($value);
$id = $prefix . 'ExcludeUrlPatterns';
$html = '<div class="mb-0">';
$html .= sprintf(
' <label for="%s" class="form-label small mb-1 fw-semibold">',
esc_attr($id)
);
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
$html .= ' Excluir por patrones URL';
$html .= ' </label>';
$html .= sprintf(
' <textarea id="%s" class="form-control form-control-sm" rows="3" placeholder="/privado/&#10;/landing-especial/&#10;/^\/categoria\/\d+$/">%s</textarea>',
esc_attr($id),
esc_textarea($patterns)
);
$html .= ' <small class="text-muted">Un patron por linea. Soporta texto simple o regex (ej: /^\/blog\/\d+$/)</small>';
$html .= '</div>';
return $html;
}
/**
* Convierte JSON array o array a lista separada por comas
*
* @param string|array $value Valor desde BD (puede ser JSON string o array ya deserializado)
*/
private function jsonToCommaList(string|array $value): string
{
// Si ya es array, usarlo directamente
if (is_array($value)) {
return empty($value) ? '' : implode(', ', $value);
}
// Si es string, intentar decodificar JSON
$decoded = json_decode($value, true);
if (!is_array($decoded) || empty($decoded)) {
return '';
}
return implode(', ', $decoded);
}
/**
* Convierte JSON array o array a lista separada por lineas
*
* @param string|array $value Valor desde BD (puede ser JSON string o array ya deserializado)
*/
private function jsonToLineList(string|array $value): string
{
// Si ya es array, usarlo directamente
if (is_array($value)) {
return empty($value) ? '' : implode("\n", $value);
}
// Si es string, intentar decodificar JSON
$decoded = json_decode($value, true);
if (!is_array($decoded) || empty($decoded)) {
return '';
}
return implode("\n", $decoded);
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
}

View File

@@ -0,0 +1,93 @@
<?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'],
// Page Visibility (grupo especial _page_visibility)
'socialShareVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'socialShareVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'socialShareVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'socialShareVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'socialShareVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'socialShareExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'socialShareExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'socialShareExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'socialShareExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,579 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'socialShare');
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,97 @@
<?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'],
// Page Visibility (grupo especial _page_visibility)
'tocVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'tocVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'tocVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'tocVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'tocVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'tocExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'tocExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'tocExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'tocExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,638 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* 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);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'toc');
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
}

View File

@@ -0,0 +1,37 @@
<?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 [
// Layout
'themeSettingsContainerMaxWidth' => ['group' => 'layout', 'attribute' => 'container_max_width'],
'themeSettingsContentColumnWidth' => ['group' => 'layout', 'attribute' => 'content_column_width'],
// 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,213 @@
<?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
* (JavaScript personalizado)
*
* NOTA: CSS personalizado se gestiona desde CustomCSSManager (TIPO 3)
* Analytics y AdSense se gestionan desde el componente adsense-placement
*
* @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);
// Layout Group
$html .= $this->buildLayoutGroup($componentId);
// JavaScript Personalizado (solo 1 card)
$html .= $this->buildJsGroup($componentId);
return $html;
}
private function buildLayoutGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-4" 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-layout-wtf me-2" style="color: #FF8600;"></i>';
$html .= ' Layout y Contenedor';
$html .= ' </h5>';
$html .= ' <div class="row g-3">';
// Container Max Width
$html .= ' <div class="col-md-6">';
$containerWidth = $this->renderer->getFieldValue($componentId, 'layout', 'container_max_width', '1320');
$containerWidthStr = is_string($containerWidth) ? $containerWidth : '1320';
$html .= $this->buildSelect(
'themeSettingsContainerMaxWidth',
'Ancho maximo del contenedor',
$containerWidthStr,
[
'1140' => '1140px (Bootstrap md)',
'1200' => '1200px (Compacto)',
'1320' => '1320px (Bootstrap xxl - Default)',
'1400' => '1400px (Amplio)',
'100%' => '100% (Fluido)'
],
'Valores menores dejan mas espacio para Rail Ads laterales'
);
$html .= ' </div>';
// Content Column Width
$html .= ' <div class="col-md-6">';
$columnWidth = $this->renderer->getFieldValue($componentId, 'layout', 'content_column_width', 'col-lg-9');
$columnWidthStr = is_string($columnWidth) ? $columnWidth : 'col-lg-9';
$html .= $this->buildSelect(
'themeSettingsContentColumnWidth',
'Ancho columna de contenido',
$columnWidthStr,
[
'col-lg-8' => '8 columnas (66.67%)',
'col-lg-9' => '9 columnas (75% - Default)',
'col-lg-10' => '10 columnas (83.33%)',
'col-lg-12' => '12 columnas (100% sin sidebar)'
],
'Proporcion de la columna principal vs sidebar'
);
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="alert alert-info small mb-0 mt-3">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' Reduce el ancho del contenedor para dar mas espacio a los Rail Ads en pantallas grandes.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildSelect(string $id, string $label, string $value, array $options, string $helpText = ''): string
{
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($id) . '">';
foreach ($options as $optionValue => $optionLabel) {
$selected = ($value === (string) $optionValue) ? ' selected' : '';
$html .= ' <option value="' . esc_attr($optionValue) . '"' . $selected . '>' . esc_html($optionLabel) . '</option>';
}
$html .= ' </select>';
if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . esc_html($helpText) . '</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 .= ' Layout y JavaScript 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 buildJsGroup(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-filetype-js me-2" style="color: #FF8600;"></i>';
$html .= ' JavaScript Personalizado';
$html .= ' </h5>';
$customJsHeader = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_header', '');
$html .= $this->buildTextareaCode(
'themeSettingsCustomJsHeader',
'JavaScript en Header',
$customJsHeader,
'Se inyecta en wp_head. No incluir etiquetas &lt;script&gt;',
5
);
$customJsFooter = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_footer', '');
$html .= $this->buildTextareaCode(
'themeSettingsCustomJsFooter',
'JavaScript en Footer',
$customJsFooter,
'Se inyecta en wp_footer. No incluir etiquetas &lt;script&gt;',
5
);
$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 JS puede afectar el rendimiento y seguridad del sitio.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildTextareaCode(string $id, string $label, mixed $value, string $helpText = '', int $rows = 4): 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 .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <textarea class="form-control form-control-sm font-monospace" id="' . esc_attr($id) . '" rows="' . $rows . '" 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,65 @@
<?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'],
'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
'topBarHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'topBarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'topBarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'topBarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'topBarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'topBarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'topBarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'topBarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'topBarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// 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,385 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
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>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'topBar');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarIsCritical" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarIsCritical" style="color: #495057;">';
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$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;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$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(' %s', esc_html($label));
$html .= ' </label>';
$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>

View File

@@ -0,0 +1 @@
@media (max-width:575.98px){:root{--bs-gutter-x:1rem}body{font-size:14px}h1{font-size:24px}h2{font-size:20px}h3{font-size:18px}.container-fluid{padding:0 10px}.navbar{padding:0.5rem 0}.navbar-brand{font-size:18px}main{padding:0.5rem}.sidebar{margin-top:2rem}table{font-size:12px;margin-bottom:1rem;overflow-x:auto}.table-responsive{margin-bottom:1rem}.btn{padding:0.375rem 0.75rem;font-size:14px}.btn-lg{padding:0.5rem 1rem;font-size:16px}.card{margin-bottom:1rem}.form-group{margin-bottom:1rem}.form-control{padding:0.375rem 0.75rem;font-size:16px}.modal-dialog{margin:0.5rem}.modal-content{border-radius:4px}img{max-width:100%;height:auto}ul,ol{padding-left:1.5rem}.mt-1,.my-1{margin-top:0.25rem !important}.mb-1,.my-1{margin-bottom:0.25rem !important}.p-1{padding:0.25rem !important}}@media (min-width:576px){body{font-size:14px}h1{font-size:28px}h2{font-size:22px}h3{font-size:18px}}@media (min-width:768px){body{font-size:15px}h1{font-size:32px}h2{font-size:26px}h3{font-size:20px}.row-md-2{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}.navbar{padding:1rem 0}.main-content{display:grid;grid-template-columns:1fr 300px;gap:2rem}.main-content.no-sidebar{grid-template-columns:1fr}}@media (min-width:992px){body{font-size:16px}h1{font-size:36px}h2{font-size:28px}h3{font-size:22px}.row-lg-3{display:grid;grid-template-columns:repeat(3,1fr);gap:2rem}.main-content{display:grid;grid-template-columns:1fr 300px;gap:2rem}.main-content.with-left-sidebar{grid-template-columns:250px 1fr 300px}.content-wrapper{max-width:1200px;margin:0 auto}}

View File

@@ -0,0 +1 @@
:root{--color-navy-dark:#0E2337;--color-navy-primary:#1e3a5f;--color-navy-light:#2c5282;--color-blue-primary:#1e3a5f;--color-blue-secondary:#2c5282;--color-blue-light:#1a73e8;--color-cyan-primary:#61c7cd;--color-cyan-dark:#4db8c4;--color-cyan-darker:#4fb3b9;--color-orange-primary:#FF8600;--color-orange-secondary:#FFB800;--color-orange-light:#FFB800;--color-orange-button:#FF6B35;--color-orange-button-end:#FF8C42;--color-orange-hover:#FF6B35;--color-neutral-50:#f8f9fa;--color-neutral-100:#e9ecef;--color-neutral-600:#495057;--color-neutral-700:#6c757d;--color-slate-gray:#4C5C6B;--color-gray-50:#f8f9fa;--color-gray-100:#f7fafc;--color-gray-200:#e9ecef;--color-gray-300:#dee2e6;--color-gray-400:#cbd5e0;--color-gray-500:#a0aec0;--color-gray-600:#6c757d;--color-gray-700:#495057;--color-gray-800:#333;--color-gray-900:#212529;--color-gray-dark:#1a1a1a;--color-white:#ffffff;--color-black:#000000;--font-family-base:'Poppins',sans-serif;--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--font-size-base:1rem;--font-size-sm:0.875rem;--font-size-lg:1.125rem;--font-size-xl:1.25rem;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--line-height-base:1.5;--line-height-tight:1.25;--line-height-loose:1.8;--spacing-xs:0.25rem;--spacing-sm:0.5rem;--spacing-md:1rem;--spacing-lg:1.5rem;--spacing-xl:2rem;--spacing-2xl:3rem;--spacing-3xl:4rem;--border-width:1px;--border-width-thick:2px;--border-width-thicker:3px;--border-width-lateral:4px;--border-radius-sm:4px;--border-radius-md:8px;--border-radius-lg:12px;--border-radius-xl:16px;--border-color-light:var(--color-gray-200);--border-color-default:var(--color-gray-300);--shadow-xs:0 1px 2px rgba(0,0,0,0.05);--shadow-sm:0 2px 4px rgba(0,0,0,0.1);--shadow-md:0 4px 12px rgba(0,0,0,0.15);--shadow-lg:0 8px 24px rgba(0,0,0,0.2);--shadow-xl:0 12px 32px rgba(0,0,0,0.25);--shadow-2xl:0 20px 60px rgba(0,0,0,0.3);--shadow-navbar:0 2px 4px rgba(0,0,0,0.15);--shadow-navbar-scrolled:0 4px 12px rgba(0,0,0,0.25);--shadow-dropdown:0 8px 24px rgba(0,0,0,0.12);--shadow-cta:0 8px 24px rgba(255,133,0,0.3);--shadow-cta-hover:0 12px 32px rgba(255,133,0,0.4);--shadow-button:0 4px 12px rgba(255,107,53,0.3);--shadow-related-posts:0 12px 32px rgba(26,115,232,0.15);--shadow-pagination:0 4px 12px rgba(26,115,232,0.3);--transition-fast:0.15s ease;--transition-base:0.3s ease;--transition-slow:0.5s ease;--transition-cubic:cubic-bezier(0.4,0,0.2,1);--z-dropdown:1000;--z-sticky:1020;--z-navbar:1030;--z-modal-backdrop:1040;--z-modal:1050;--z-popover:1060;--z-tooltip:1070;--gradient-hero:linear-gradient(135deg,var(--color-blue-primary) 0%,var(--color-blue-secondary) 100%);--gradient-cta:linear-gradient(135deg,var(--color-orange-primary) 0%,var(--color-orange-secondary) 100%);--gradient-button-lets-talk:linear-gradient(135deg,var(--color-orange-button) 0%,var(--color-orange-button-end) 100%);--gradient-pagination:linear-gradient(135deg,var(--color-blue-primary) 0%,var(--color-blue-secondary) 100%);--gradient-underline:linear-gradient(90deg,var(--color-cyan-primary) 0%,var(--color-cyan-dark) 100%);--gradient-border-related:linear-gradient(180deg,var(--color-blue-primary) 0%,var(--color-blue-light) 100%);--opacity-disabled:0.5;--opacity-hover:0.8;--opacity-backdrop:0.5;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--breakpoint-xxl:1400px}

View File

@@ -0,0 +1,821 @@
/**
* Critical Bootstrap CSS Subset (TIPO 2)
*
* Contiene SOLO clases de Bootstrap 5.3.2 usadas en componentes above-the-fold.
* NO contiene CSS personalizado (ese va en critical-custom-temp.css - TIPO 3).
*
* Componentes Bootstrap incluidos:
* - Fonts (@font-face Poppins)
* - Variables CSS (:root)
* - Resets (box-sizing, body)
* - Container system
* - Grid system (row, col-*)
* - Flexbox utilities (d-flex, justify-content-*, align-items-*)
* - Spacing utilities (m-*, p-*)
* - Text utilities
* - Navbar component
* - Collapse/Dropdown components
* - Button component
* - Alert component
* - Typography base (h1-h6, p)
* - Responsive breakpoints
*
* Hook: wp_head priority 0
* Output: <style id="roi-critical-bootstrap">
*
* @version 5.3.2-subset
* @see Inc/enqueue-scripts.php - Bootstrap diferido
* @see Shared/Infrastructure/Services/CriticalBootstrapService.php
* @see Assets/Css/critical-custom-temp.css - CSS personalizado (TIPO 3)
*/
/* ==========================================================================
CRITICAL FONTS (Poppins - LCP optimization)
font-display: swap + preload = fuente carga rapido y siempre se muestra
size-adjust: 100.6% = fallback casi identico a Poppins (minimiza CLS)
========================================================================== */
@font-face {
font-family: 'Poppins Fallback';
src: local('Arial'), local('Helvetica Neue'), local('sans-serif');
size-adjust: 106%;
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
@font-face {
font-family: 'Poppins';
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
/* Fonts */
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
--bs-body-font-family: 'Poppins', 'Poppins Fallback', sans-serif;
/* Theme Colors (críticos para above-the-fold) */
--color-navy-dark: #0E2337;
--color-navy-medium: #1e3a5f;
--color-orange-primary: #FF8600;
--color-orange-hover: #e67a00;
--bs-primary: #0d6efd;
--bs-white: #fff;
--bs-body-color: #212529;
--bs-body-bg: #fff;
--bs-link-color: #0d6efd;
--bs-link-hover-color: #0a58ca;
/* Spacing */
--bs-gutter-x: 1.5rem;
}
/* ==========================================================================
BOX SIZING & RESETS (Bootstrap Reboot crítico)
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--bs-body-font-family, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif);
font-size: var(--bs-body-font-size, 1rem);
font-weight: var(--bs-body-font-weight, 400);
line-height: var(--bs-body-line-height, 1.5);
color: var(--bs-body-color, #212529);
background-color: var(--bs-body-bg, #fff);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
a {
color: var(--bs-link-color, #0d6efd);
text-decoration: underline;
}
a:hover {
color: var(--bs-link-hover-color, #0a58ca);
}
img, svg {
vertical-align: middle;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
/* ==========================================================================
CONTAINER (Layout crítico)
========================================================================== */
.container,
.container-fluid {
--bs-gutter-x: 1.5rem;
--bs-gutter-y: 0;
width: 100%;
padding-right: calc(var(--bs-gutter-x) * 0.5);
padding-left: calc(var(--bs-gutter-x) * 0.5);
margin-right: auto;
margin-left: auto;
}
@media (min-width: 576px) {
.container { max-width: 540px; }
}
@media (min-width: 768px) {
.container { max-width: 720px; }
}
@media (min-width: 992px) {
.container { max-width: 960px; }
}
@media (min-width: 1200px) {
.container { max-width: 1140px; }
}
@media (min-width: 1400px) {
.container { max-width: 1320px; }
}
/* ==========================================================================
GRID SYSTEM (Layout crítico - Previene CLS)
========================================================================== */
.row {
--bs-gutter-x: 1.5rem;
--bs-gutter-y: 0;
display: flex;
flex-wrap: wrap;
margin-top: calc(-1 * var(--bs-gutter-y));
margin-right: calc(-0.5 * var(--bs-gutter-x));
margin-left: calc(-0.5 * var(--bs-gutter-x));
}
.row > * {
flex-shrink: 0;
width: 100%;
max-width: 100%;
padding-right: calc(var(--bs-gutter-x) * 0.5);
padding-left: calc(var(--bs-gutter-x) * 0.5);
margin-top: var(--bs-gutter-y);
}
.col { flex: 1 0 0%; }
.col-auto { flex: 0 0 auto; width: auto; }
.col-1 { flex: 0 0 auto; width: 8.33333333%; }
.col-2 { flex: 0 0 auto; width: 16.66666667%; }
.col-3 { flex: 0 0 auto; width: 25%; }
.col-4 { flex: 0 0 auto; width: 33.33333333%; }
.col-5 { flex: 0 0 auto; width: 41.66666667%; }
.col-6 { flex: 0 0 auto; width: 50%; }
.col-7 { flex: 0 0 auto; width: 58.33333333%; }
.col-8 { flex: 0 0 auto; width: 66.66666667%; }
.col-9 { flex: 0 0 auto; width: 75%; }
.col-10 { flex: 0 0 auto; width: 83.33333333%; }
.col-11 { flex: 0 0 auto; width: 91.66666667%; }
.col-12 { flex: 0 0 auto; width: 100%; }
@media (min-width: 768px) {
.col-md-1 { flex: 0 0 auto; width: 8.33333333%; }
.col-md-2 { flex: 0 0 auto; width: 16.66666667%; }
.col-md-3 { flex: 0 0 auto; width: 25%; }
.col-md-4 { flex: 0 0 auto; width: 33.33333333%; }
.col-md-5 { flex: 0 0 auto; width: 41.66666667%; }
.col-md-6 { flex: 0 0 auto; width: 50%; }
.col-md-7 { flex: 0 0 auto; width: 58.33333333%; }
.col-md-8 { flex: 0 0 auto; width: 66.66666667%; }
.col-md-9 { flex: 0 0 auto; width: 75%; }
.col-md-10 { flex: 0 0 auto; width: 83.33333333%; }
.col-md-11 { flex: 0 0 auto; width: 91.66666667%; }
.col-md-12 { flex: 0 0 auto; width: 100%; }
}
@media (min-width: 992px) {
.col-lg-1 { flex: 0 0 auto; width: 8.33333333%; }
.col-lg-2 { flex: 0 0 auto; width: 16.66666667%; }
.col-lg-3 { flex: 0 0 auto; width: 25%; }
.col-lg-4 { flex: 0 0 auto; width: 33.33333333%; }
.col-lg-5 { flex: 0 0 auto; width: 41.66666667%; }
.col-lg-6 { flex: 0 0 auto; width: 50%; }
.col-lg-7 { flex: 0 0 auto; width: 58.33333333%; }
.col-lg-8 { flex: 0 0 auto; width: 66.66666667%; }
.col-lg-9 { flex: 0 0 auto; width: 75%; }
.col-lg-10 { flex: 0 0 auto; width: 83.33333333%; }
.col-lg-11 { flex: 0 0 auto; width: 91.66666667%; }
.col-lg-12 { flex: 0 0 auto; width: 100%; }
}
/* Gutter utilities */
.g-0, .gx-0 { --bs-gutter-x: 0; }
.g-0, .gy-0 { --bs-gutter-y: 0; }
.g-3, .gx-3 { --bs-gutter-x: 1rem; }
.g-3, .gy-3 { --bs-gutter-y: 1rem; }
/* ==========================================================================
FLEXBOX UTILITIES (Layout crítico)
========================================================================== */
.d-flex {
display: flex !important;
}
.d-none {
display: none !important;
}
.d-block {
display: block !important;
}
.d-inline-block {
display: inline-block !important;
}
/* Responsive Display Utilities - Previene CLS en TopNotificationBar */
@media (min-width: 992px) {
.d-lg-none {
display: none !important;
}
.d-lg-block {
display: block !important;
}
.d-lg-flex {
display: flex !important;
}
}
.flex-wrap {
flex-wrap: wrap !important;
}
.flex-column {
flex-direction: column !important;
}
.justify-content-center {
justify-content: center !important;
}
.justify-content-between {
justify-content: space-between !important;
}
.justify-content-start {
justify-content: flex-start !important;
}
.justify-content-end {
justify-content: flex-end !important;
}
.align-items-center {
align-items: center !important;
}
.align-items-start {
align-items: flex-start !important;
}
.align-items-end {
align-items: flex-end !important;
}
.gap-2 {
gap: 0.5rem !important;
}
.gap-3 {
gap: 1rem !important;
}
/* ==========================================================================
SPACING UTILITIES (Margin/Padding críticos)
========================================================================== */
.m-0 { margin: 0 !important; }
.m-auto { margin: auto !important; }
.mb-0 { margin-bottom: 0 !important; }
.mb-1 { margin-bottom: 0.25rem !important; }
.mb-2 { margin-bottom: 0.5rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.mb-4 { margin-bottom: 1.5rem !important; }
.mt-0 { margin-top: 0 !important; }
.mt-2 { margin-top: 0.5rem !important; }
.mt-3 { margin-top: 1rem !important; }
.me-1 { margin-right: 0.25rem !important; }
.me-2 { margin-right: 0.5rem !important; }
.me-3 { margin-right: 1rem !important; }
.ms-2 { margin-left: 0.5rem !important; }
.ms-3 { margin-left: 1rem !important; }
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
.p-0 { padding: 0 !important; }
.p-2 { padding: 0.5rem !important; }
.p-3 { padding: 1rem !important; }
.py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
.px-2 { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
.px-3 { padding-left: 1rem !important; padding-right: 1rem !important; }
.px-4 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
/* ==========================================================================
SIZING UTILITIES (Width/Height críticos)
========================================================================== */
.w-100 { width: 100% !important; }
.w-auto { width: auto !important; }
.h-100 { height: 100% !important; }
.h-auto { height: auto !important; }
/* ==========================================================================
TEXT UTILITIES (Críticos para layout)
========================================================================== */
.text-center { text-align: center !important; }
.text-start { text-align: left !important; }
.text-end { text-align: right !important; }
.text-white { color: #fff !important; }
.text-muted { color: var(--bs-secondary-color, #6c757d) !important; }
.fw-normal { font-weight: 400 !important; }
.fw-medium { font-weight: 500 !important; }
.fw-semibold { font-weight: 600 !important; }
.fw-bold { font-weight: 700 !important; }
.fs-5 { font-size: 1.25rem !important; }
.fs-6 { font-size: 1rem !important; }
.small { font-size: 0.875em !important; }
@media (min-width: 768px) {
.text-md-start { text-align: left !important; }
.text-md-center { text-align: center !important; }
.text-md-end { text-align: right !important; }
}
/* ==========================================================================
NAVBAR COMPONENT (Crítico - Above the fold)
========================================================================== */
.navbar {
--bs-navbar-padding-x: 0;
--bs-navbar-padding-y: 0.5rem;
--bs-navbar-color: rgba(255, 255, 255, 0.55);
--bs-navbar-hover-color: rgba(255, 255, 255, 0.75);
--bs-navbar-disabled-color: rgba(255, 255, 255, 0.25);
--bs-navbar-active-color: #fff;
--bs-navbar-brand-padding-y: 0.3125rem;
--bs-navbar-brand-margin-end: 1rem;
--bs-navbar-brand-font-size: 1.25rem;
--bs-navbar-brand-color: #fff;
--bs-navbar-brand-hover-color: #fff;
--bs-navbar-nav-link-padding-x: 0.5rem;
--bs-navbar-toggler-padding-y: 0.25rem;
--bs-navbar-toggler-padding-x: 0.75rem;
--bs-navbar-toggler-font-size: 1.25rem;
--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
--bs-navbar-toggler-border-color: rgba(255, 255, 255, 0.1);
--bs-navbar-toggler-border-radius: var(--bs-border-radius, 0.375rem);
--bs-navbar-toggler-focus-width: 0.25rem;
--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;
/* position: controlado por CriticalCSSService según sticky_enabled */
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: var(--bs-navbar-padding-y) var(--bs-navbar-padding-x);
}
.navbar > .container,
.navbar > .container-fluid {
display: flex;
flex-wrap: inherit;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
padding-top: var(--bs-navbar-brand-padding-y);
padding-bottom: var(--bs-navbar-brand-padding-y);
margin-right: var(--bs-navbar-brand-margin-end);
font-size: var(--bs-navbar-brand-font-size);
color: var(--bs-navbar-brand-color);
text-decoration: none;
white-space: nowrap;
}
.navbar-brand:hover,
.navbar-brand:focus {
color: var(--bs-navbar-brand-hover-color);
}
.navbar-nav {
--bs-nav-link-padding-x: 0;
--bs-nav-link-padding-y: 0.5rem;
--bs-nav-link-color: var(--bs-navbar-color);
--bs-nav-link-hover-color: var(--bs-navbar-hover-color);
--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);
display: flex;
flex-direction: column;
padding-left: 0;
margin-bottom: 0;
list-style: none;
}
.navbar-nav .nav-link {
padding-right: 0;
padding-left: 0;
color: var(--bs-nav-link-color);
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
color: var(--bs-nav-link-hover-color);
}
.navbar-nav .nav-link.active {
color: var(--bs-navbar-active-color);
}
.nav-link {
display: block;
padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);
font-size: var(--bs-nav-link-font-size);
font-weight: var(--bs-nav-link-font-weight);
color: var(--bs-nav-link-color);
text-decoration: none;
background: 0 0;
border: 0;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
}
.nav-item {
margin-bottom: 0;
}
.navbar-toggler {
padding: var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);
font-size: var(--bs-navbar-toggler-font-size);
line-height: 1;
color: var(--bs-navbar-color);
background-color: transparent;
border: var(--bs-border-width, 1px) solid var(--bs-navbar-toggler-border-color);
border-radius: var(--bs-navbar-toggler-border-radius);
transition: var(--bs-navbar-toggler-transition);
}
.navbar-toggler:hover {
text-decoration: none;
}
.navbar-toggler:focus {
text-decoration: none;
outline: 0;
box-shadow: 0 0 0 var(--bs-navbar-toggler-focus-width);
}
.navbar-toggler-icon {
display: inline-block;
width: 1.5em;
height: 1.5em;
vertical-align: middle;
background-image: var(--bs-navbar-toggler-icon-bg);
background-repeat: no-repeat;
background-position: center;
background-size: 100%;
}
.navbar-dark,
.navbar[data-bs-theme="dark"] {
--bs-navbar-color: rgba(255, 255, 255, 0.55);
--bs-navbar-hover-color: rgba(255, 255, 255, 0.75);
--bs-navbar-disabled-color: rgba(255, 255, 255, 0.25);
--bs-navbar-active-color: #fff;
--bs-navbar-brand-color: #fff;
--bs-navbar-brand-hover-color: #fff;
--bs-navbar-toggler-border-color: rgba(255, 255, 255, 0.1);
--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
}
/* ==========================================================================
COLLAPSE COMPONENT (Navbar mobile)
========================================================================== */
.collapse:not(.show) {
display: none;
}
.navbar-collapse {
flex-basis: 100%;
flex-grow: 1;
align-items: center;
}
/* ==========================================================================
DROPDOWN COMPONENT (Navbar submenus)
========================================================================== */
.dropdown {
position: relative;
}
.dropdown-toggle {
white-space: nowrap;
}
.dropdown-toggle::after {
display: inline-block;
margin-left: 0.255em;
vertical-align: 0.255em;
content: "";
border-top: 0.3em solid;
border-right: 0.3em solid transparent;
border-bottom: 0;
border-left: 0.3em solid transparent;
}
.dropdown-menu {
--bs-dropdown-zindex: 1000;
--bs-dropdown-min-width: 10rem;
--bs-dropdown-padding-x: 0;
--bs-dropdown-padding-y: 0.5rem;
--bs-dropdown-spacer: 0.125rem;
--bs-dropdown-font-size: 1rem;
--bs-dropdown-color: var(--bs-body-color, #212529);
--bs-dropdown-bg: var(--bs-body-bg, #fff);
--bs-dropdown-border-color: var(--bs-border-color-translucent, rgba(0,0,0,.175));
--bs-dropdown-border-radius: var(--bs-border-radius, 0.375rem);
--bs-dropdown-border-width: var(--bs-border-width, 1px);
--bs-dropdown-inner-border-radius: calc(var(--bs-border-radius, 0.375rem) - var(--bs-border-width, 1px));
--bs-dropdown-divider-bg: var(--bs-border-color-translucent, rgba(0,0,0,.175));
--bs-dropdown-divider-margin-y: 0.5rem;
--bs-dropdown-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-dropdown-link-color: var(--bs-body-color, #212529);
--bs-dropdown-link-hover-color: var(--bs-body-color, #212529);
--bs-dropdown-link-hover-bg: var(--bs-tertiary-bg, #f8f9fa);
--bs-dropdown-link-active-color: #fff;
--bs-dropdown-link-active-bg: #0d6efd;
--bs-dropdown-link-disabled-color: var(--bs-tertiary-color, #adb5bd);
--bs-dropdown-item-padding-x: 1rem;
--bs-dropdown-item-padding-y: 0.25rem;
--bs-dropdown-header-color: #6c757d;
--bs-dropdown-header-padding-x: 1rem;
--bs-dropdown-header-padding-y: 0.5rem;
position: absolute;
z-index: var(--bs-dropdown-zindex);
display: none;
min-width: var(--bs-dropdown-min-width);
padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);
margin: 0;
font-size: var(--bs-dropdown-font-size);
color: var(--bs-dropdown-color);
text-align: left;
list-style: none;
background-color: var(--bs-dropdown-bg);
background-clip: padding-box;
border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);
border-radius: var(--bs-dropdown-border-radius);
}
.dropdown-menu.show {
display: block;
}
.dropdown-item {
display: block;
width: 100%;
padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);
clear: both;
font-weight: 400;
color: var(--bs-dropdown-link-color);
text-align: inherit;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border: 0;
}
.dropdown-item:hover,
.dropdown-item:focus {
color: var(--bs-dropdown-link-hover-color);
background-color: var(--bs-dropdown-link-hover-bg);
}
.dropdown-item.active,
.dropdown-item:active {
color: var(--bs-dropdown-link-active-color);
text-decoration: none;
background-color: var(--bs-dropdown-link-active-bg);
}
/* ==========================================================================
TEXT UTILITIES
========================================================================== */
.text-decoration-underline {
text-decoration: underline !important;
}
.text-decoration-none {
text-decoration: none !important;
}
/* ==========================================================================
IMAGE UTILITIES
========================================================================== */
.img-fluid {
max-width: 100%;
height: auto;
}
/* ==========================================================================
ALERT COMPONENT (Above-the-fold notifications)
========================================================================== */
.alert {
--bs-alert-padding-x: 1rem;
--bs-alert-padding-y: 1rem;
--bs-alert-margin-bottom: 1rem;
--bs-alert-border-radius: 0.375rem;
position: relative;
padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x);
margin-bottom: var(--bs-alert-margin-bottom);
border: 1px solid transparent;
border-radius: var(--bs-alert-border-radius);
}
.alert-warning {
--bs-alert-color: #664d03;
--bs-alert-bg: #fff3cd;
--bs-alert-border-color: #ffecb5;
color: var(--bs-alert-color);
background-color: var(--bs-alert-bg);
border-color: var(--bs-alert-border-color);
}
.alert-info {
--bs-alert-color: #055160;
--bs-alert-bg: #cff4fc;
--bs-alert-border-color: #b6effb;
color: var(--bs-alert-color);
background-color: var(--bs-alert-bg);
border-color: var(--bs-alert-border-color);
}
/* ==========================================================================
BUTTON COMPONENT (Above-the-fold - Navbar CTA)
========================================================================== */
.btn {
--bs-btn-padding-x: 0.75rem;
--bs-btn-padding-y: 0.375rem;
--bs-btn-font-size: 1rem;
--bs-btn-font-weight: 400;
--bs-btn-line-height: 1.5;
--bs-btn-color: var(--bs-body-color);
--bs-btn-bg: transparent;
--bs-btn-border-width: var(--bs-border-width, 1px);
--bs-btn-border-color: transparent;
--bs-btn-border-radius: var(--bs-border-radius, 0.375rem);
--bs-btn-hover-border-color: transparent;
--bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
--bs-btn-disabled-opacity: 0.65;
--bs-btn-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), 0.5);
display: inline-block;
padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x);
font-family: var(--bs-btn-font-family);
font-size: var(--bs-btn-font-size);
font-weight: var(--bs-btn-font-weight);
line-height: var(--bs-btn-line-height);
color: var(--bs-btn-color);
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
user-select: none;
border: var(--bs-btn-border-width) solid var(--bs-btn-border-color);
border-radius: var(--bs-btn-border-radius);
background-color: var(--bs-btn-bg);
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.btn:hover {
color: var(--bs-btn-hover-color);
background-color: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.btn:focus-visible {
color: var(--bs-btn-hover-color);
background-color: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
outline: 0;
box-shadow: var(--bs-btn-focus-box-shadow);
}
.btn:disabled, .btn.disabled {
pointer-events: none;
opacity: var(--bs-btn-disabled-opacity);
}
/* ==========================================================================
BUTTON CLOSE (Dismiss notification)
========================================================================== */
.btn-close {
--bs-btn-close-color: #000;
--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");
--bs-btn-close-opacity: 0.5;
--bs-btn-close-hover-opacity: 0.75;
--bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
--bs-btn-close-focus-opacity: 1;
--bs-btn-close-disabled-opacity: 0.25;
--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
box-sizing: content-box;
width: 1em;
height: 1em;
padding: 0.25em 0.25em;
color: var(--bs-btn-close-color);
background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat;
border: 0;
border-radius: 0.375rem;
opacity: var(--bs-btn-close-opacity);
}
.btn-close:hover {
color: var(--bs-btn-close-color);
text-decoration: none;
opacity: var(--bs-btn-close-hover-opacity);
}
.btn-close:focus {
outline: 0;
box-shadow: var(--bs-btn-close-focus-shadow);
opacity: var(--bs-btn-close-focus-opacity);
}
.btn-close-white {
filter: var(--bs-btn-close-white-filter);
}
/* ==========================================================================
RESPONSIVE BREAKPOINTS (navbar-expand-lg)
========================================================================== */
@media (min-width: 992px) {
.navbar-expand-lg {
flex-wrap: nowrap;
justify-content: flex-start;
}
.navbar-expand-lg .navbar-nav {
flex-direction: row;
}
.navbar-expand-lg .navbar-nav .dropdown-menu {
position: absolute;
}
.navbar-expand-lg .navbar-nav .nav-link {
padding-right: var(--bs-navbar-nav-link-padding-x);
padding-left: var(--bs-navbar-nav-link-padding-x);
}
.navbar-expand-lg .navbar-collapse {
display: flex !important;
flex-basis: auto;
}
.navbar-expand-lg .navbar-toggler {
display: none;
}
.d-lg-block { display: block !important; }
.d-lg-none { display: none !important; }
.mb-lg-0 { margin-bottom: 0 !important; }
}
@media (max-width: 991.98px) {
.navbar-expand-lg > .container,
.navbar-expand-lg > .container-fluid {
padding-right: 0;
padding-left: 0;
}
}
/* ==========================================================================
RESPONSIVE DISPLAY UTILITIES (md breakpoint)
========================================================================== */
@media (min-width: 768px) {
.d-md-block { display: block !important; }
.d-md-none { display: none !important; }
}
/* ==========================================================================
TYPOGRAPHY BASE (Critical)
========================================================================== */
p {
margin-top: 0;
margin-bottom: 1rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 { font-size: calc(1.375rem + 1.5vw); }
h2 { font-size: calc(1.325rem + 0.9vw); }
h3 { font-size: calc(1.3rem + 0.6vw); }
h4 { font-size: calc(1.275rem + 0.3vw); }
h5 { font-size: 1.25rem; }
h6 { font-size: 1rem; }
@media (min-width: 1200px) {
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.75rem; }
h4 { font-size: 1.5rem; }
}

View File

@@ -5,7 +5,7 @@
* screen reader utilities, and minimum touch targets.
* Compliant with WCAG 2.1 Level AA standards.
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -662,7 +662,7 @@ select:valid {
*/
/* Links del TOC con focus visible */
.apus-toc a:focus,
.roi-toc a:focus,
.toc-link:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
@@ -671,7 +671,7 @@ select:valid {
}
/* Item activo del TOC */
.apus-toc a.active,
.roi-toc a.active,
.toc-link.active {
font-weight: bold;
border-left: 4px solid #0066cc;
@@ -679,11 +679,11 @@ select:valid {
}
/* Botón toggle del TOC con ARIA */
.apus-toc-toggle[aria-expanded="true"]::before {
.roi-toc-toggle[aria-expanded="true"]::before {
content: "▼ ";
}
.apus-toc-toggle[aria-expanded="false"]::before {
.roi-toc-toggle[aria-expanded="false"]::before {
content: "▶ ";
}

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
* Animation Styles
*
* CSS animations and keyframes for the theme
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/

View File

@@ -4,7 +4,7 @@
* NOTA: Todos los estilos de badges están en style.css según template original.
* Este archivo se mantiene vacío para evitar duplicaciones.
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/

View File

@@ -1,5 +1,5 @@
/**
* Sistema de Tipografías - APUS Theme
* Sistema de Tipografías - ROI Theme
*
* RESPONSABILIDAD: SOLO definición de fuentes y variables tipográficas
* - Declaraciones @font-face (comentadas - usar Google Fonts)
@@ -11,7 +11,7 @@
* - Estilos de elementos HTML (van en style.css)
* - Variables de colores o espaciados (van en variables.css)
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -24,11 +24,12 @@
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
/* Fuente primaria - Poppins según template y documentación */
--font-primary: 'Poppins', sans-serif;
/* Fuente primaria - Poppins con fallback ajustado (Fase 4.3 PageSpeed)
'Poppins Fallback' tiene size-adjust para reducir CLS durante font swap */
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para encabezados - Poppins según template */
--font-headings: 'Poppins', sans-serif;
/* Fuente para encabezados - Poppins con fallback ajustado */
--font-headings: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para código (monospace) */
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono',
@@ -45,26 +46,41 @@
*/
/* ============================================
POPPINS (Opcional - Solo si se activa)
POPPINS (Self-hosted)
============================================
Las siguientes declaraciones @font-face solo
se cargan cuando el usuario activa "Use Custom Fonts"
en Apariencia > Personalizar > Tipografía.
Fuentes Poppins alojadas localmente para:
- Eliminar dependencia de Google Fonts
- Mejorar rendimiento (sin requests externos)
- Cumplimiento GDPR (sin tracking de Google)
Para activar Poppins:
1. Descargar archivos WOFF2 de Google Fonts
2. Colocar en assets/fonts/poppins/
3. Descomentar las declaraciones @font-face
4. Activar en Customizer
Pesos incluidos: 400, 500, 600, 700
Formato: WOFF2 (mejor compresión)
Fase 4.3 PageSpeed: Fallback con size-adjust para reducir CLS
- size-adjust: 100.6% ajustado para coincidir mejor con Poppins
- font-display: swap + preload = carga rapida sin salto visual
- Preload en CriticalCSSInjector P:-2 acelera descarga de fuentes
NOTA: El valor 100.6% fue calibrado empiricamente.
- 106% causaba un salto visual notable (navbar se "achicaba")
- 100.6% minimiza el CLS manteniendo legibilidad del fallback
============================================ */
/*
/* Fallback font con metricas ajustadas para Poppins */
@font-face {
font-family: 'Poppins Fallback';
src: local('Arial'), local('Helvetica Neue'), local('Helvetica'), local('sans-serif');
size-adjust: 106%;
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
@font-face {
font-family: 'Poppins';
src: url('../fonts/poppins/Poppins-Regular.woff2') format('woff2'),
url('../fonts/poppins/Poppins-Regular.woff') format('woff');
src: url('../Fonts/poppins-v24-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
@@ -72,8 +88,7 @@
@font-face {
font-family: 'Poppins';
src: url('../fonts/poppins/Poppins-Medium.woff2') format('woff2'),
url('../fonts/poppins/Poppins-Medium.woff') format('woff');
src: url('../Fonts/poppins-v24-latin-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
@@ -81,8 +96,7 @@
@font-face {
font-family: 'Poppins';
src: url('../fonts/poppins/Poppins-SemiBold.woff2') format('woff2'),
url('../fonts/poppins/Poppins-SemiBold.woff') format('woff');
src: url('../Fonts/poppins-v24-latin-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
@@ -90,21 +104,11 @@
@font-face {
font-family: 'Poppins';
src: url('../fonts/poppins/Poppins-Bold.woff2') format('woff2'),
url('../fonts/poppins/Poppins-Bold.woff') format('woff');
src: url('../Fonts/poppins-v24-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
*/
/* Cuando Poppins esté activo, se aplica con clase .use-custom-fonts */
/*
.use-custom-fonts {
--font-primary: 'Poppins', var(--font-system);
--font-headings: 'Poppins', var(--font-system);
}
*/
/* ============================================
UTILIDADES DE FUENTES

View File

@@ -4,7 +4,7 @@
* Estilos para tablas genéricas en post-content (NO tablas APU)
* Aplica 10 estilos diferentes automáticamente a las primeras 11 tablas
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -12,7 +12,7 @@
BASE STYLES - Todas las tablas genéricas
======================================== */
.post-content table:not(.analisis table) {
.post-content table:not(.analisis table):not(.desglose table) {
width: 100%;
border-collapse: collapse;
margin: 2rem auto;
@@ -23,9 +23,9 @@
}
/* Header styles - VERY OBVIOUS */
.post-content table:not(.analisis table) thead tr:first-child th,
.post-content table:not(.analisis table) tbody tr:first-child td,
.post-content table:not(.analisis table) tr:first-child td {
.post-content table:not(.analisis table):not(.desglose table) thead tr:first-child th,
.post-content table:not(.analisis table):not(.desglose table) tbody tr:first-child td,
.post-content table:not(.analisis table):not(.desglose table) tr:first-child td {
font-weight: 700;
text-align: center;
padding: 1.25rem 1rem;
@@ -34,7 +34,7 @@
}
/* Body cells */
.post-content table:not(.analisis table) tbody tr:not(:first-child) td {
.post-content table:not(.analisis table):not(.desglose table) tbody tr:not(:first-child) td {
padding: 0.875rem 1rem;
border: 1px solid var(--color-neutral-100);
text-align: left;
@@ -63,13 +63,14 @@
/* ========================================
STYLE 2: Orange Header with Light Background
Fase 4.4 Accesibilidad: Texto oscuro para contraste WCAG AA (4.5:1)
======================================== */
.post-content table:not(.analisis table):nth-of-type(3) thead tr:first-child th,
.post-content table:not(.analisis table):nth-of-type(3) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(3) tr:first-child td {
background: var(--color-orange-primary);
color: #ffffff !important;
color: var(--color-navy-dark) !important;
border: none !important;
box-shadow: 0 2px 8px rgba(255, 133, 0, 0.3);
}
@@ -126,13 +127,14 @@
/* ========================================
STYLE 5: Orange Gradient Header
Fase 4.4 Accesibilidad: Texto oscuro para contraste WCAG AA (4.5:1)
======================================== */
.post-content table:not(.analisis table):nth-of-type(6) thead tr:first-child th,
.post-content table:not(.analisis table):nth-of-type(6) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(6) tr:first-child td {
background: linear-gradient(135deg, var(--color-orange-primary) 0%, var(--color-orange-light) 100%);
color: #ffffff !important;
color: var(--color-navy-dark) !important;
border: none !important;
box-shadow: 0 2px 8px rgba(255, 133, 0, 0.35);
}
@@ -168,13 +170,14 @@
/* ========================================
STYLE 7: Light Orange Background
Fase 4.4 Accesibilidad: Texto oscuro para contraste WCAG AA (4.5:1)
======================================== */
.post-content table:not(.analisis table):nth-of-type(8) thead tr:first-child th,
.post-content table:not(.analisis table):nth-of-type(8) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(8) tr:first-child td {
background: var(--color-orange-primary);
color: #ffffff !important;
color: var(--color-navy-dark) !important;
border: none !important;
border-bottom: 3px solid var(--color-navy-primary) !important;
}
@@ -235,6 +238,7 @@
/* ========================================
STYLE 10: Bold Orange Border
Fase 4.4 Accesibilidad: Texto oscuro para contraste WCAG AA (4.5:1)
======================================== */
.post-content table:not(.analisis table):nth-of-type(11) {
@@ -245,7 +249,7 @@
.post-content table:not(.analisis table):nth-of-type(11) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(11) tr:first-child td {
background: linear-gradient(135deg, var(--color-orange-hover) 0%, var(--color-orange-primary) 100%);
color: #ffffff !important;
color: var(--color-navy-dark) !important;
border: none !important;
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4);
}

View File

@@ -4,7 +4,7 @@
* Estilos personalizados para paginación
* Template ref: css/style.css líneas 180-207
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -37,9 +37,7 @@
color: var(--color-orange-primary);
background-color: rgba(255, 133, 0, 0.1);
border-color: var(--color-orange-primary);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(255, 133, 0, 0.15);
z-index: 2;
text-decoration: none;
}
.page-link:focus {
@@ -53,17 +51,8 @@
/* Active page */
.page-item.active .page-link {
color: #ffffff;
background: var(--color-orange-primary);
background-color: var(--color-orange-primary);
border-color: var(--color-orange-primary);
font-weight: 600;
box-shadow: 0 4px 12px rgba(255, 133, 0, 0.3);
z-index: 3;
}
.page-item.active .page-link:hover {
background: var(--color-orange-light);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 133, 0, 0.4);
}
/* Disabled state */

View File

@@ -2,7 +2,7 @@
* Print Styles
*
* Optimized styling for printing
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/

View File

@@ -2,7 +2,7 @@
* Responsive Design Styles
*
* Media queries and responsive adjustments
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -246,31 +246,12 @@
font-size: 24px;
}
.container {
max-width: 1140px;
}
.container-lg {
max-width: 1280px;
}
.container-xl {
max-width: 1400px;
}
}
/* XXL devices (1400px and up) */
@media (min-width: 1400px) {
.container {
max-width: 1320px;
}
.container-xl {
max-width: 1500px;
}
/* Container width uses CSS variable from Theme Settings */
.container,
.container-lg,
.container-xl,
.container-xxl {
max-width: 1700px;
max-width: var(--roi-container-width, 1320px);
}
}

View File

@@ -5,7 +5,7 @@
* IMPORTANTE: Bootstrap 5 ya provee la mayoría de utilities (display, flex, spacing, etc.)
* Este archivo solo contiene utilities adicionales no incluidas en Bootstrap
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -88,3 +88,43 @@
.transition-none {
transition: none !important;
}
/* ========================================
COMPONENT VISIBILITY FAILSAFE (Plan 99.15)
CSS failsafe: Oculta wrappers de componentes
cuando body tiene clases roi-hide-*
Estas clases se agregan via BodyClassHooksRegistrar
cuando los componentes están deshabilitados/excluidos.
======================================== */
/* Navbar hidden */
body.roi-hide-navbar .navbar {
display: none !important;
}
/* Table of Contents hidden */
body.roi-hide-toc .roi-toc-container {
display: none !important;
}
/* CTA Sidebar hidden */
body.roi-hide-cta-sidebar .roi-cta-box {
display: none !important;
}
/* Generic sidebar hidden */
body.roi-hide-sidebar .sidebar-sticky {
display: none !important;
}
/* When ALL sidebar components are hidden, expand main column */
body.roi-sidebar-empty .col-lg-9 {
flex: 0 0 100% !important;
max-width: 100% !important;
}
body.roi-sidebar-empty .col-lg-3 {
display: none !important;
}

View File

@@ -17,7 +17,7 @@
* - Clases utilitarias (van en utilities.css o style.css)
* - Estilos aplicados (SOLO variables en :root)
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/

View File

@@ -3,7 +3,7 @@
*
* Estilos para videos embebidos (YouTube, Vimeo, etc.) en post-content
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/

View File

@@ -28,6 +28,10 @@
border-radius: 8px;
border: none;
border-spacing: 0;
/* CRITICO: table-layout fixed previene CLS
El navegador calcula anchos basado en primera fila,
no recalcula cuando carga más contenido */
table-layout: fixed;
}
/* Eliminar todos los bordes */
@@ -153,12 +157,13 @@
text-align: left;
}
/* Columna 3: Unidad - centrada */
/* Columna 3: Unidad - centrada
Fase 4.4 Accesibilidad: Color #495057 (ratio 7.0:1) en lugar de #6c757d */
.analisis table td:nth-child(3),
.analisis table td.c3,
.desglose table td.c3 {
text-align: center !important;
color: #6c757d;
color: #495057;
font-size: 0.9em;
}
@@ -214,16 +219,17 @@
/* ========================================
FILAS DE SUBTOTALES
(Suma de Material, Suma de Mano de Obra, etc)
Fase 4.4 Accesibilidad: Color oscuro para contraste WCAG AA (4.5:1)
======================================== */
.analisis table tr.subtotal-row,
.desglose table tr.subtotal-row {
background-color: rgba(255, 133, 0, 0.1) !important;
background-color: rgba(255, 133, 0, 0.15) !important;
}
.analisis table tr.subtotal-row td,
.desglose table tr.subtotal-row td {
font-weight: 700;
color: var(--color-orange-primary);
color: #1e3a5f;
padding: 0.875rem 1rem;
border: none !important;
}
@@ -235,7 +241,7 @@
.analisis table tr.subtotal-row td.c6,
.analisis table tr.subtotal-row td:nth-child(6) {
font-size: 1.05rem;
color: var(--color-orange-primary);
color: #1e3a5f;
}
/* ========================================

View File

@@ -1,5 +1,5 @@
/**
* APUS Theme - Main Stylesheet
* ROI Theme - Main Stylesheet
*
* RESPONSABILIDAD: Estilos principales del tema
* - Variables CSS específicas del tema (:root en este archivo)
@@ -12,7 +12,7 @@
* - variables.css: SOLO variables de colores/espaciados/etc
* - style.css: Aplica variables a elementos HTML (este archivo)
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -23,7 +23,7 @@
IMPORTANTE: Este archivo style.css es para estilos GLOBALES del tema únicamente.
El CSS de componentes individuales DEBE ir en archivos separados en:
wp-content/themes/apus-theme/assets/css/[nombre-componente].css
wp-content/themes/roi-theme/assets/css/[nombre-componente].css
Ejemplos de componentes con archivos individuales:
- CTA Box Sidebar cta-box-sidebar.css
@@ -43,7 +43,7 @@
========================================
El CSS de Share Buttons DEBE estar en:
wp-content/themes/apus-theme/assets/css/social-share.css
wp-content/themes/roi-theme/assets/css/social-share.css
Este archivo ya existe y está correctamente enqueued.
Ver: inc/enqueue-scripts.php líneas 405-421
@@ -55,7 +55,7 @@
========================================
El CSS de CTA A/B Testing DEBE estar en:
wp-content/themes/apus-theme/assets/css/cta.css
wp-content/themes/roi-theme/assets/css/cta.css
Este archivo ya existe y está correctamente enqueued.
Ver: inc/enqueue-scripts.php líneas 443-477
@@ -67,7 +67,7 @@
========================================
El CSS de Related Posts DEBE estar en:
wp-content/themes/apus-theme/assets/css/related-posts.css
wp-content/themes/roi-theme/assets/css/related-posts.css
Este archivo ya existe y está correctamente enqueued.
Ver: inc/enqueue-scripts.php líneas 148-156
@@ -79,7 +79,7 @@
========================================
El CSS de Pagination DEBE estar en:
wp-content/themes/apus-theme/assets/css/pagination.css
wp-content/themes/roi-theme/assets/css/pagination.css
Este archivo ya existe y está correctamente enqueued.
Ver: inc/enqueue-scripts.php líneas 129-136
@@ -91,7 +91,7 @@
========================================
El CSS de Footer Contact Form DEBE estar en:
wp-content/themes/apus-theme/assets/css/footer-contact.css
wp-content/themes/roi-theme/assets/css/footer-contact.css
Este archivo ya existe y está correctamente enqueued.
Ver: inc/enqueue-scripts.php líneas 506-517
@@ -146,7 +146,7 @@
--color-text: #212529; /* Contrast ratio 15.52:1 against white */
--color-bg: #ffffff;
/* APU Template Colors (from apus-theme-template/css/style.css) */
/* APU Template Colors (from roi-theme-template/css/style.css) */
--color-navy-dark: #0E2337;
--color-navy-primary: #1e3a5f;
--color-navy-light: #2c5282;
@@ -341,6 +341,11 @@ img {
.content-wrapper {
grid-template-columns: 2fr 1fr;
}
/* Full width when no sidebar */
.no-sidebar .content-wrapper {
grid-template-columns: 1fr;
}
}
#primary {

1
Assets/Css/style.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,7 +4,7 @@
* Mejoras de accesibilidad para navegación por teclado, gestión de focus,
* y cumplimiento de WCAG 2.1 Level AA.
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/

View File

@@ -4,7 +4,7 @@
* Este script retrasa la carga de Google AdSense hasta que haya interacción
* del usuario o se cumpla un timeout, mejorando el rendimiento de carga inicial.
*
* @package Apus_Theme
* @package ROI_Theme
* @since 1.0.0
*/
@@ -182,12 +182,74 @@
}, CONFIG.timeout);
}
/**
* Activa slots de AdSense insertados dinamicamente
* Escucha el evento 'roi-adsense-activate' disparado por otros scripts
*/
function setupDynamicAdsListener() {
window.addEventListener('roi-adsense-activate', function() {
debugLog('Evento roi-adsense-activate recibido');
// Si AdSense aun no ha cargado, forzar carga ahora
if (!adsenseLoaded) {
debugLog('AdSense no cargado, forzando carga...');
loadAdSense();
return;
}
// AdSense ya cargado - activar nuevos slots
debugLog('Activando nuevos slots dinamicos...');
activateDynamicSlots();
});
}
/**
* Activa slots de AdSense que fueron insertados despues de la carga inicial
*/
function activateDynamicSlots() {
// Buscar scripts de push que aun no han sido ejecutados
var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]');
if (pendingPushScripts.length === 0) {
debugLog('No hay slots pendientes por activar');
return;
}
debugLog('Activando ' + pendingPushScripts.length + ' slot(s) dinamico(s)');
// Asegurar que adsbygoogle existe
window.adsbygoogle = window.adsbygoogle || [];
pendingPushScripts.forEach(function(oldScript) {
try {
// Crear nuevo script ejecutable
var newScript = document.createElement('script');
newScript.type = 'text/javascript';
newScript.innerHTML = oldScript.innerHTML;
// Reemplazar el placeholder con el script real
oldScript.parentNode.replaceChild(newScript, oldScript);
} catch (e) {
debugLog('Error activando slot: ' + e.message);
}
});
}
/**
* Inicializa el cargador retrasado de AdSense
*/
function init() {
// =========================================================================
// NUEVO: Siempre configurar listener para ads dinamicos
// IMPORTANTE: Esto debe ejecutarse ANTES del early return
// porque los ads dinamicos pueden necesitar activarse aunque
// el delay global este deshabilitado
// =========================================================================
setupDynamicAdsListener();
debugLog('Listener para ads dinamicos configurado');
// Verificar si el retardo de AdSense está habilitado
if (!window.apusAdsenseDelayed) {
if (!window.roiAdsenseDelayed) {
debugLog('Retardo de AdSense no habilitado');
return;
}

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