184 Commits

Author SHA1 Message Date
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
357 changed files with 82370 additions and 3827 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]
}
}

4
.gitignore vendored
View File

@@ -40,9 +40,6 @@ Desktop.ini
node_modules/ node_modules/
npm-debug.log npm-debug.log
# Composer (si hay dependencias PHP)
vendor/
composer.lock
# PHPUnit # PHPUnit
.phpunit.result.cache .phpunit.result.cache
@@ -76,5 +73,4 @@ _testing-suite/
# Claude Code tools # Claude Code tools
.playwright-mcp/ .playwright-mcp/
.serena/ .serena/
.claude/
nul nul

1
.husky/commit-msg Normal file
View File

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

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

@@ -26,7 +26,19 @@ final class ContactFormFieldMapper implements FieldMapperInterface
'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'contactFormShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// 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 // Content
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'], 'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui; namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Contact Form * FormBuilder para Contact Form
@@ -93,19 +94,47 @@ final class ContactFormFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // =============================================
$html .= ' <div class="mb-0 mt-3">'; // Checkboxes de visibilidad por tipo de página
$html .= ' <label for="contactFormShowOnPages" class="form-label small mb-1 fw-semibold">'; // Grupo especial: _page_visibility
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; // =============================================
$html .= ' Mostrar en'; $html .= ' <hr class="my-3">';
$html .= ' </label>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <select id="contactFormShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>'; $html .= ' </p>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>'; $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>'; $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>';
$html .= '</div>'; $html .= '</div>';
@@ -598,4 +627,26 @@ final class ContactFormFormBuilder
return $html; 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

@@ -30,7 +30,20 @@ final class CtaBoxSidebarFieldMapper implements FieldMapperInterface
'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], '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 // Content
'ctaTitle' => ['group' => 'content', 'attribute' => 'title'], 'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui; namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para el CTA Box Sidebar * FormBuilder para el CTA Box Sidebar
@@ -94,18 +95,61 @@ final class CtaBoxSidebarFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // 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', false);
$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="mb-0 mt-3">';
$html .= ' <label for="ctaShowOnPages" class="form-label small mb-1 fw-semibold">'; $html .= ' <div class="form-check form-switch">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <input class="form-check-input" type="checkbox" id="ctaHideForLoggedIn" ';
$html .= ' Mostrar en'; $html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' </label>'; $html .= ' <label class="form-check-label small" for="ctaHideForLoggedIn" style="color: #495057;">';
$html .= ' <select id="ctaShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>'; $html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>'; $html .= ' </label>';
$html .= ' </select>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
@@ -515,4 +559,29 @@ final class CtaBoxSidebarFormBuilder
return $html; 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

@@ -26,7 +26,20 @@ final class CtaLetsTalkFieldMapper implements FieldMapperInterface
'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaLetsTalkShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], '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 // Content
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'], 'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui; namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* Class CtaLetsTalkFormBuilder * Class CtaLetsTalkFormBuilder
@@ -120,16 +121,73 @@ final class CtaLetsTalkFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Select: Show on Pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // 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="mb-0">';
$html .= ' <label for="ctaLetsTalkShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>'; $html .= ' <div class="form-check form-switch">';
$html .= ' <select id="ctaLetsTalkShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">'; $html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkHideForLoggedIn" ';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>'; $html .= ' <label class="form-check-label small" for="ctaLetsTalkHideForLoggedIn" style="color: #495057;">';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' </select>'; $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>'; $html .= ' </div>';
@@ -447,4 +505,26 @@ final class CtaLetsTalkFormBuilder
return $html; 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

@@ -26,7 +26,20 @@ final class CtaPostFieldMapper implements FieldMapperInterface
'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], '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 // Content
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'], 'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui; namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para CTA Post * FormBuilder para CTA Post
@@ -85,17 +86,59 @@ final class CtaPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
// 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="mb-0 mt-3">';
$html .= ' <label for="ctaPostShowOnPages" class="form-label small mb-1 fw-semibold">'; $html .= ' <div class="form-check form-switch">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <input class="form-check-input" type="checkbox" id="ctaPostHideForLoggedIn" ';
$html .= ' Mostrar en'; $html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' </label>'; $html .= ' <label class="form-check-label small" for="ctaPostHideForLoggedIn" style="color: #495057;">';
$html .= ' <select id="ctaPostShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>'; $html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesion iniciada</small>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>'; $html .= ' </label>';
$html .= ' </select>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
@@ -437,4 +480,26 @@ final class CtaPostFormBuilder
return $html; 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,121 @@
<?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 dos formatos:
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
* 2. Legacy/Migración: 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 = '/^[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)) {
if (!preg_match(self::PATTERN_GENERATED, $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,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,462 @@
<?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\SaveSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* 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
*
* 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;
private SaveSnippetUseCase $saveUseCase;
private DeleteSnippetUseCase $deleteUseCase;
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {
// Crear repositorio y Use Cases internamente
global $wpdb;
$this->repository = new WordPressSnippetRepository($wpdb);
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
$this->saveUseCase = new SaveSnippetUseCase($this->repository);
$this->deleteUseCase = new DeleteSnippetUseCase($this->repository);
// Registrar handler de formulario POST
$this->registerFormHandler();
}
/**
* Registra handler para procesar formularios POST
*/
private function registerFormHandler(): void
{
// Solo registrar una vez
static $registered = false;
if ($registered) {
return;
}
$registered = true;
add_action('admin_init', function() {
$this->handleFormSubmission();
});
}
/**
* Procesa envío de formulario
*/
public function handleFormSubmission(): void
{
if (!isset($_POST['roi_css_action'])) {
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');
}
$action = sanitize_text_field($_POST['roi_css_action']);
try {
match ($action) {
'save' => $this->processSave($_POST),
'delete' => $this->processDelete($_POST),
default => null,
};
// Redirect con mensaje de éxito
wp_redirect(add_query_arg('roi_message', 'success', wp_get_referer()));
exit;
} catch (ValidationException $e) {
// Redirect con mensaje de error
wp_redirect(add_query_arg([
'roi_message' => 'error',
'roi_error' => urlencode($e->getMessage())
], wp_get_referer()));
exit;
}
}
/**
* Procesa guardado de snippet
*/
private function processSave(array $data): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
// Generar ID si es nuevo
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),
]);
$this->saveUseCase->execute($request);
}
/**
* Procesa eliminación de snippet
*/
private function processDelete(array $data): void
{
$id = sanitize_text_field($data['snippet_id'] ?? '');
if (empty($id)) {
throw new ValidationException('ID de snippet requerido para eliminar');
}
$this->deleteUseCase->execute($id);
}
/**
* 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));
// Mensajes flash
if ($message) {
$html .= sprintf(
'<div class="alert alert-%s m-3">%s</div>',
esc_attr($message['type']),
esc_html($message['text'])
);
}
// 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-save me-1"></i> Guardar Snippet';
$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' => 'Snippet guardado correctamente'];
}
if ($message === 'error') {
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
return ['type' => 'danger', 'text' => $error];
}
return null;
}
}

View File

@@ -26,7 +26,19 @@ final class FeaturedImageFieldMapper implements FieldMapperInterface
'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'featuredImageShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// 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 // Content
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'], 'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui; namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class FeaturedImageFormBuilder final class FeaturedImageFormBuilder
{ {
@@ -100,25 +101,75 @@ final class FeaturedImageFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
$html .= ' <div class="mb-0 mt-3">'; // Checkboxes de visibilidad por tipo de página
$html .= ' <label for="featuredImageShowOnPages" class="form-label small mb-1 fw-semibold">'; // Grupo especial: _page_visibility
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; // =============================================
$html .= ' Mostrar en'; $html .= ' <hr class="my-3">';
$html .= ' </label>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <select id="featuredImageShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' </p>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>'; $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>'; $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>';
$html .= '</div>'; $html .= '</div>';
return $html; 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 private function buildContentGroup(string $componentId): string
{ {
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">'; $html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';

View File

@@ -27,6 +27,19 @@ final class FooterFieldMapper implements FieldMapperInterface
'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'footerShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], '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 // Widget 1
'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'], 'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'],
'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'], 'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui; namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Footer * FormBuilder para Footer
@@ -90,6 +91,47 @@ final class FooterFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $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>';
$html .= '</div>'; $html .= '</div>';
@@ -410,4 +452,19 @@ final class FooterFormBuilder
} }
return (string) $value; 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

@@ -26,7 +26,20 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], '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 // Content
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'], 'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui; namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class HeroFormBuilder final class HeroFormBuilder
{ {
@@ -102,18 +103,59 @@ final class HeroFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
// 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="mb-0 mt-3">';
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">'; $html .= ' <div class="form-check form-switch">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <input class="form-check-input" type="checkbox" id="heroIsCritical" ';
$html .= ' Mostrar en'; $html .= checked($isCritical, true, false) . '>';
$html .= ' </label>'; $html .= ' <label class="form-check-label small" for="heroIsCritical">';
$html .= ' <select id="heroShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= ' <strong>CSS Crítico</strong>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $html .= ' </label>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>'; $html .= ' </div>';
$html .= ' </select>';
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
@@ -413,4 +455,26 @@ final class HeroFormBuilder
return $html; 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

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace ROITheme\Admin\Infrastructure\Api\Wordpress; namespace ROITheme\Admin\Infrastructure\Api\WordPress;
use ROITheme\Admin\Domain\Contracts\MenuRegistrarInterface; use ROITheme\Admin\Domain\Contracts\MenuRegistrarInterface;
use ROITheme\Admin\Domain\ValueObjects\MenuItem; use ROITheme\Admin\Domain\ValueObjects\MenuItem;

View File

@@ -107,6 +107,15 @@ final class AdminAssetEnqueuer
true 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 // Pasar variables al JavaScript
wp_localize_script( wp_localize_script(
'roi-admin-dashboard', 'roi-admin-dashboard',

View File

@@ -18,10 +18,12 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
/** /**
* @param GetComponentSettingsUseCase|null $getComponentSettingsUseCase * @param GetComponentSettingsUseCase|null $getComponentSettingsUseCase
* @param ComponentGroupRegistry|null $groupRegistry Registro de grupos de componentes
* @param array<string, mixed> $components Componentes disponibles * @param array<string, mixed> $components Componentes disponibles
*/ */
public function __construct( public function __construct(
private readonly ?GetComponentSettingsUseCase $getComponentSettingsUseCase = null, private readonly ?GetComponentSettingsUseCase $getComponentSettingsUseCase = null,
private readonly ?ComponentGroupRegistry $groupRegistry = null,
private readonly array $components = [] private readonly array $components = []
) { ) {
} }
@@ -111,6 +113,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'Theme Settings', 'label' => 'Theme Settings',
'icon' => 'bi-gear', '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',
],
]; ];
} }
@@ -153,13 +165,51 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
*/ */
public function getFormBuilderClass(string $componentId): string public function getFormBuilderClass(string $componentId): string
{ {
// Convertir kebab-case a PascalCase // Mapeo especial para componentes con acrónimos (CSS, API, etc.)
// 'top-notification-bar' → 'TopNotificationBar' $specialMappings = [
$className = str_replace('-', '', ucwords($componentId, '-')); '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 // Construir namespace completo
// ROITheme\Admin\TopNotificationBar\Infrastructure\Ui\TopNotificationBarFormBuilder // ROITheme\Admin\TopNotificationBar\Infrastructure\Ui\TopNotificationBarFormBuilder
return "ROITheme\\Admin\\{$className}\\Infrastructure\\Ui\\{$className}FormBuilder"; 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

@@ -1,17 +1,53 @@
/** /**
* Estilos para el Dashboard del Panel de Administración ROI Theme * ROI Theme Admin Dashboard - Improved Version
* Siguiendo especificaciones del Design System * 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 */ /* Sobrescribir max-width de .card de WordPress */
.wrap.roi-admin-panel .card { .wrap.roi-admin-panel .card {
max-width: none !important; max-width: none !important;
} }
/* Fix para switches de Bootstrap - resetear completamente estilos de WordPress */ /* Fix para switches de Bootstrap */
.wrap.roi-admin-panel .form-switch .form-check-input { .wrap.roi-admin-panel .form-switch .form-check-input {
all: unset !important; all: unset !important;
/* Restaurar estilos necesarios de Bootstrap */
width: 2em !important; width: 2em !important;
height: 1em !important; height: 1em !important;
margin-left: -2.5em !important; margin-left: -2.5em !important;
@@ -49,7 +85,6 @@
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important;
} }
/* Alinear verticalmente los labels con los switches */
.wrap.roi-admin-panel .form-check { .wrap.roi-admin-panel .form-check {
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
@@ -62,7 +97,10 @@
padding-top: 0 !important; padding-top: 0 !important;
} }
/* Tabs Navigation */ /* ================================================
TABS NAVIGATION (Legacy)
================================================ */
.nav-tabs-admin { .nav-tabs-admin {
border-bottom: 2px solid #e9ecef; border-bottom: 2px solid #e9ecef;
} }
@@ -77,7 +115,7 @@
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
padding: 0.3rem 0.3rem; padding: 0.3rem 0.3rem;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.83rem;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@@ -113,8 +151,394 @@
} }
} }
/* Responsive */ /* ================================================
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) { @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 { .nav-tabs-admin {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -125,7 +549,40 @@
} }
} }
@media (max-width: 767px) { @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 { .nav-tabs-admin {
overflow-x: auto; overflow-x: auto;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -135,3 +592,39 @@
white-space: nowrap; 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

@@ -10,12 +10,73 @@
* Inicializa el dashboard cuando el DOM está listo * Inicializa el dashboard cuando el DOM está listo
*/ */
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initializeTabs(); // Nueva navegación por Cards/Grupos
initializeCardNavigation();
// Funcionalidad existente (solo si hay tabs visibles)
if (document.querySelector('.nav-tabs-admin')) {
initializeTabs();
}
initializeFormValidation(); initializeFormValidation();
initializeButtons(); initializeButtons();
initializeColorPickers(); 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 * Inicializa el sistema de tabs con persistencia en URL
*/ */

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']
],
'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

@@ -2,6 +2,8 @@
/** /**
* ROI Theme - Panel de Administración Principal * ROI Theme - Panel de Administración Principal
* *
* Nueva UI con sistema de Cards/Grupos (App-Style Navigation)
*
* @var AdminDashboardRenderer $this * @var AdminDashboardRenderer $this
*/ */
@@ -13,76 +15,34 @@ if (!defined('ABSPATH')) {
} }
$components = $this->getComponents(); $components = $this->getComponents();
$groups = $this->getComponentGroups();
// Determinar tab activo: desde URL o primer componente // =====================================================
$activeComponentId = array_key_first($components); // SANITIZACIÓN OBLIGATORIA según estándares WordPress
// =====================================================
// Leer parametro admin-tab de la URL con sanitizacion // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Solo lectura de parámetro para UI
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Solo lectura de parametro para UI $activeComponent = null;
if (isset($_GET['admin-tab'])) { if (isset($_GET['component'])) {
$requestedTab = sanitize_text_field(wp_unslash($_GET['admin-tab'])); $requestedComponent = sanitize_text_field(wp_unslash($_GET['component']));
// Validar que el componente exista // Validar que el componente exista
if (array_key_exists($requestedTab, $components)) { if (array_key_exists($requestedComponent, $components)) {
$activeComponentId = $requestedTab; $activeComponent = $requestedComponent;
} }
} }
?> ?>
<div class="wrap roi-admin-panel"> <div class="wrap roi-admin-panel">
<!-- Navigation Tabs -->
<ul class="nav nav-tabs nav-tabs-admin mb-0" role="tablist">
<?php foreach ($components as $componentId => $component): ?>
<li class="nav-item" role="presentation">
<button class="nav-link <?php echo $componentId === $activeComponentId ? 'active' : ''; ?>"
data-bs-toggle="tab"
data-bs-target="#<?php echo esc_attr($componentId); ?>Tab"
type="button"
role="tab"
aria-controls="<?php echo esc_attr($componentId); ?>Tab"
aria-selected="<?php echo $componentId === $activeComponentId ? 'true' : 'false'; ?>">
<i class="bi <?php echo esc_attr($component['icon']); ?> me-1"></i>
<?php echo esc_html($component['label']); ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<!-- Tab Content --> <?php if ($activeComponent !== null): ?>
<div class="tab-content mt-3"> <!-- =====================================================
<?php foreach ($components as $componentId => $component): Vista de Componente Individual
$isActive = ($componentId === $activeComponentId); ===================================================== -->
$componentSettings = $this->getComponentSettings($componentId); <?php include __DIR__ . '/partials/component-view.php'; ?>
?> <?php else: ?>
<!-- Tab: <?php echo esc_html($component['label']); ?> --> <!-- =====================================================
<div class="tab-pane fade <?php echo $isActive ? 'show active' : ''; ?>" Vista Home: Grupos y Cards
id="<?php echo esc_attr($componentId); ?>Tab" ===================================================== -->
role="tabpanel"> <?php include __DIR__ . '/partials/groups-home.php'; ?>
<?php endif; ?>
<?php
// Renderizar FormBuilder del componente
$formBuilderClass = $this->getFormBuilderClass($componentId);
if (class_exists($formBuilderClass)) {
$formBuilder = new $formBuilderClass($this);
echo $formBuilder->buildForm($componentId);
} else {
echo '<p class="text-danger">FormBuilder no encontrado: ' . esc_html($formBuilderClass) . '</p>';
}
?>
</div>
<?php endforeach; ?>
</div>
<!-- Botones Globales Save/Cancel -->
<div class="d-flex justify-content-end gap-2 p-3 rounded border mt-4" style="background-color: #f8f9fa; border-color: #e9ecef !important;">
<button type="button" class="btn btn-outline-secondary" id="cancelChanges">
<i class="bi bi-x-circle me-1"></i>
Cancelar
</button>
<button type="button" id="saveSettings" class="btn fw-semibold text-white" style="background-color: #FF8600; border-color: #FF8600;">
<i class="bi bi-check-circle me-1"></i>
Guardar Cambios
</button>
</div>
</div><!-- /wrap --> </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,74 @@
<?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>
<!-- 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>
</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

@@ -26,8 +26,21 @@ final class NavbarFieldMapper implements FieldMapperInterface
'navbarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'navbarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'navbarShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'navbarShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'navbarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'], '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 // Layout
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'], 'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\Ui; namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class NavbarFormBuilder final class NavbarFormBuilder
{ {
@@ -105,21 +106,50 @@ final class NavbarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Select: Show on Pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-2">'; // Grupo especial: _page_visibility
$html .= ' <label for="navbarShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>'; // =============================================
$html .= ' <select id="navbarShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">'; $html .= ' <hr class="my-3">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $html .= ' </p>';
$html .= ' </select>';
$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>'; $html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'navbar');
// Switch: Sticky // Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true); $sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
$html .= ' <div class="mb-0">'; $html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">'; $html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarSticky" name="visibility[sticky_enabled]" '; $html .= ' <input class="form-check-input" type="checkbox" id="navbarSticky" name="visibility[sticky_enabled]" ';
$html .= checked($sticky, true, false) . '>'; $html .= checked($sticky, true, false) . '>';
@@ -129,6 +159,19 @@ final class NavbarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$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>';
$html .= '</div>'; $html .= '</div>';
@@ -514,4 +557,26 @@ final class NavbarFormBuilder
return $html; 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

@@ -29,7 +29,19 @@ final class RelatedPostFieldMapper implements FieldMapperInterface
'relatedPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'relatedPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'relatedPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'relatedPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'relatedPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'relatedPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'relatedPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// 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 // Content
'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'], 'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui; namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Related Posts * FormBuilder para Related Posts
@@ -86,19 +87,47 @@ final class RelatedPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
$html .= ' <div class="mb-0 mt-3">'; // Checkboxes de visibilidad por tipo de página
$html .= ' <label for="relatedPostShowOnPages" class="form-label small mb-1 fw-semibold">'; // Grupo especial: _page_visibility
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; // =============================================
$html .= ' Mostrar en'; $html .= ' <hr class="my-3">';
$html .= ' </label>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <select id="relatedPostShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>'; $html .= ' </p>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>'; $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>'; $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>';
$html .= '</div>'; $html .= '</div>';
@@ -498,4 +527,26 @@ final class RelatedPostFormBuilder
return $html; 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

@@ -1,10 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Api\Wordpress; namespace ROITheme\Admin\Shared\Infrastructure\Api\WordPress;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase; use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
use ROITheme\Admin\Shared\Infrastructure\FieldMapping\FieldMapperRegistry; use ROITheme\Admin\Shared\Infrastructure\FieldMapping\FieldMapperRegistry;
use ROITheme\Admin\Shared\Infrastructure\Services\ExclusionFieldProcessor;
/** /**
* Handler para peticiones AJAX del panel de administracion * Handler para peticiones AJAX del panel de administracion
@@ -73,10 +74,16 @@ final class AdminAjaxHandler
/** /**
* Mapea settings de field IDs a grupos/atributos * 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 private function mapSettings(array $settings, array $fieldMapping): array
{ {
$mappedSettings = []; $mappedSettings = [];
$fieldProcessor = new ExclusionFieldProcessor();
foreach ($settings as $fieldId => $value) { foreach ($settings as $fieldId => $value) {
if (!isset($fieldMapping[$fieldId])) { if (!isset($fieldMapping[$fieldId])) {
@@ -86,11 +93,17 @@ final class AdminAjaxHandler
$mapping = $fieldMapping[$fieldId]; $mapping = $fieldMapping[$fieldId];
$groupName = $mapping['group']; $groupName = $mapping['group'];
$attributeName = $mapping['attribute']; $attributeName = $mapping['attribute'];
$type = $mapping['type'] ?? null;
if (!isset($mappedSettings[$groupName])) { if (!isset($mappedSettings[$groupName])) {
$mappedSettings[$groupName] = []; $mappedSettings[$groupName] = [];
} }
// Procesar valor segun tipo
if ($type !== null && is_string($value)) {
$value = $fieldProcessor->process($value, $type);
}
$mappedSettings[$groupName][$attributeName] = $value; $mappedSettings[$groupName][$attributeName] = $value;
} }
@@ -130,7 +143,7 @@ final class AdminAjaxHandler
// Usar repositorio para restaurar valores // Usar repositorio para restaurar valores
if ($this->saveComponentSettingsUseCase !== null) { if ($this->saveComponentSettingsUseCase !== null) {
global $wpdb; global $wpdb;
$repository = new \ROITheme\Shared\Infrastructure\Persistence\Wordpress\WordPressComponentSettingsRepository($wpdb); $repository = new \ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentSettingsRepository($wpdb);
$updated = $repository->resetToDefaults($component, $schemaPath); $updated = $repository->resetToDefaults($component, $schemaPath);
wp_send_json_success([ wp_send_json_success([

View File

@@ -32,6 +32,7 @@ final class FieldMapperProvider
'ContactForm', 'ContactForm',
'Footer', 'Footer',
'ThemeSettings', 'ThemeSettings',
'AdsensePlacement',
]; ];
public function __construct( public function __construct(

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

@@ -26,7 +26,19 @@ final class SocialShareFieldMapper implements FieldMapperInterface
'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'socialShareShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// 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 // Content
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'], 'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui; namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Social Share * FormBuilder para Social Share
@@ -94,20 +95,47 @@ final class SocialShareFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-0 mt-3">'; // Grupo especial: _page_visibility
$html .= ' <label for="socialShareShowOnPages" class="form-label small mb-1 fw-semibold">'; // =============================================
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <hr class="my-3">';
$html .= ' Mostrar en'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' </label>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <select id="socialShareShowOnPages" class="form-select form-select-sm">'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' </p>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$html .= ' </select>'; $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>'; $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>';
$html .= '</div>'; $html .= '</div>';
@@ -526,4 +554,26 @@ final class SocialShareFormBuilder
return $html; 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

@@ -26,7 +26,19 @@ final class TableOfContentsFieldMapper implements FieldMapperInterface
'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'tocShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// 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 // Content
'tocTitle' => ['group' => 'content', 'attribute' => 'title'], 'tocTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui; namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para la Tabla de Contenido * FormBuilder para la Tabla de Contenido
@@ -94,20 +95,47 @@ final class TableOfContentsFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-0 mt-3">'; // Grupo especial: _page_visibility
$html .= ' <label for="tocShowOnPages" class="form-label small mb-1 fw-semibold">'; // =============================================
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <hr class="my-3">';
$html .= ' Mostrar en'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' </label>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <select id="tocShowOnPages" class="form-select form-select-sm">'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>'; $html .= ' </p>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$html .= ' </select>'; $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>'; $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>';
$html .= '</div>'; $html .= '</div>';
@@ -585,4 +613,26 @@ final class TableOfContentsFormBuilder
return $html; 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

@@ -24,13 +24,9 @@ final class ThemeSettingsFieldMapper implements FieldMapperInterface
public function getFieldMapping(): array public function getFieldMapping(): array
{ {
return [ return [
// Analytics // Layout
'themeSettingsGaTrackingId' => ['group' => 'analytics', 'attribute' => 'ga_tracking_id'], 'themeSettingsContainerMaxWidth' => ['group' => 'layout', 'attribute' => 'container_max_width'],
'themeSettingsGaAnonymizeIp' => ['group' => 'analytics', 'attribute' => 'ga_anonymize_ip'], 'themeSettingsContentColumnWidth' => ['group' => 'layout', 'attribute' => 'content_column_width'],
// AdSense
'themeSettingsAdsensePublisherId' => ['group' => 'adsense', 'attribute' => 'adsense_publisher_id'],
'themeSettingsAdsenseAutoAds' => ['group' => 'adsense', 'attribute' => 'adsense_auto_ads'],
// Custom Code // Custom Code
'themeSettingsCustomCss' => ['group' => 'custom_code', 'attribute' => 'custom_css'], 'themeSettingsCustomCss' => ['group' => 'custom_code', 'attribute' => 'custom_css'],

View File

@@ -9,9 +9,10 @@ use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
* FormBuilder para Theme Settings * FormBuilder para Theme Settings
* *
* RESPONSABILIDAD: Generar formulario de configuraciones globales del tema * RESPONSABILIDAD: Generar formulario de configuraciones globales del tema
* (analytics, adsense, codigo personalizado) * (JavaScript personalizado)
* *
* NOTA: Logo/branding se gestiona desde el componente navbar * 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 * @package ROITheme\Admin\ThemeSettings\Infrastructure\Ui
*/ */
@@ -27,20 +28,92 @@ final class ThemeSettingsFormBuilder
$html .= $this->buildHeader($componentId); $html .= $this->buildHeader($componentId);
$html .= '<div class="row g-3">'; // Layout Group
$html .= $this->buildLayoutGroup($componentId);
// Columna izquierda - Analytics + AdSense // JavaScript Personalizado (solo 1 card)
$html .= '<div class="col-lg-6">'; $html .= $this->buildJsGroup($componentId);
$html .= $this->buildAnalyticsGroup($componentId);
$html .= $this->buildAdSenseGroup($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>'; $html .= '</div>';
// Columna derecha - Custom Code return $html;
$html .= '<div class="col-lg-6">'; }
$html .= $this->buildCustomCodeGroup($componentId);
$html .= '</div>';
$html .= '</div>'; 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; return $html;
} }
@@ -56,7 +129,7 @@ final class ThemeSettingsFormBuilder
$html .= ' Configuraciones Globales del Tema'; $html .= ' Configuraciones Globales del Tema';
$html .= ' </h3>'; $html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">'; $html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Analytics, AdSense y Codigo Personalizado'; $html .= ' Layout y JavaScript Personalizado';
$html .= ' </p>'; $html .= ' </p>';
$html .= ' </div>'; $html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="theme-settings">'; $html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="theme-settings">';
@@ -69,83 +142,36 @@ final class ThemeSettingsFormBuilder
return $html; return $html;
} }
private function buildAnalyticsGroup(string $componentId): string private function buildJsGroup(string $componentId): string
{ {
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">'; $html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">'; $html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">'; $html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-graph-up me-2" style="color: #FF8600;"></i>'; $html .= ' <i class="bi bi-filetype-js me-2" style="color: #FF8600;"></i>';
$html .= ' Analytics'; $html .= ' JavaScript Personalizado';
$html .= ' </h5>'; $html .= ' </h5>';
$gaTrackingId = $this->renderer->getFieldValue($componentId, 'analytics', 'ga_tracking_id', '');
$html .= $this->buildTextInput('themeSettingsGaTrackingId', 'Google Analytics ID', 'bi-bar-chart', $gaTrackingId);
$html .= ' <div class="form-text small mb-2">Formato: G-XXXXXXXXXX o UA-XXXXXXXX-X</div>';
$gaAnonymizeIp = $this->renderer->getFieldValue($componentId, 'analytics', 'ga_anonymize_ip', true);
$html .= $this->buildSwitch('themeSettingsGaAnonymizeIp', 'Anonimizar IP (GDPR)', 'bi-shield-check', $gaAnonymizeIp);
$html .= ' <div class="alert alert-warning small mb-0 mt-2">';
$html .= ' <i class="bi bi-exclamation-triangle me-1"></i>';
$html .= ' Recomendado activar para cumplir con GDPR/RGPD';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildAdSenseGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-badge-ad me-2" style="color: #FF8600;"></i>';
$html .= ' Google AdSense';
$html .= ' </h5>';
$publisherId = $this->renderer->getFieldValue($componentId, 'adsense', 'adsense_publisher_id', '');
$html .= $this->buildTextInput('themeSettingsAdsensePublisherId', 'Publisher ID', 'bi-key', $publisherId);
$html .= ' <div class="form-text small mb-2">Formato: ca-pub-1234567890123456</div>';
$autoAds = $this->renderer->getFieldValue($componentId, 'adsense', 'adsense_auto_ads', false);
$html .= $this->buildSwitch('themeSettingsAdsenseAutoAds', 'Activar Auto Ads', 'bi-magic', $autoAds);
$html .= ' <div class="alert alert-info small mb-0 mt-2">';
$html .= ' <i class="bi bi-info-circle me-1"></i>';
$html .= ' Auto Ads permite que Google coloque anuncios automaticamente en las mejores ubicaciones.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildCustomCodeGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-code-slash me-2" style="color: #FF8600;"></i>';
$html .= ' Codigo Personalizado';
$html .= ' </h5>';
$customCss = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_css', '');
$html .= $this->buildTextareaCode('themeSettingsCustomCss', 'CSS Personalizado', 'bi-filetype-css', $customCss, 'Se inyecta en wp_head. No incluir etiquetas &lt;style&gt;');
$customJsHeader = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_header', ''); $customJsHeader = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_header', '');
$html .= $this->buildTextareaCode('themeSettingsCustomJsHeader', 'JavaScript en Header', 'bi-filetype-js', $customJsHeader, 'Se inyecta en wp_head. No incluir etiquetas &lt;script&gt;'); $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', ''); $customJsFooter = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_footer', '');
$html .= $this->buildTextareaCode('themeSettingsCustomJsFooter', 'JavaScript en Footer', 'bi-filetype-js', $customJsFooter, 'Se inyecta en wp_footer. No incluir etiquetas &lt;script&gt;'); $html .= $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 .= ' <div class="alert alert-danger small mb-0 mt-2">';
$html .= ' <i class="bi bi-exclamation-octagon me-1"></i>'; $html .= ' <i class="bi bi-exclamation-octagon me-1"></i>';
$html .= ' <strong>Advertencia:</strong> El codigo personalizado puede afectar el rendimiento y seguridad del sitio.'; $html .= ' <strong>Advertencia:</strong> El codigo JS puede afectar el rendimiento y seguridad del sitio.';
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
@@ -154,47 +180,15 @@ final class ThemeSettingsFormBuilder
return $html; return $html;
} }
// Helper methods private function buildTextareaCode(string $id, string $label, mixed $value, string $helpText = '', int $rows = 4): string
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); $value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">'; $html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">'; $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 .= ' ' . esc_html($label);
$html .= ' </label>'; $html .= ' </label>';
$html .= ' <input type="text" class="form-control form-control-sm" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '">'; $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>';
$html .= ' </div>';
return $html;
}
private function buildTextareaCode(string $id, string $label, string $icon, mixed $value, string $helpText = ''): string
{
$value = $this->normalizeStringValue($value);
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <textarea class="form-control form-control-sm font-monospace" id="' . esc_attr($id) . '" rows="4" style="font-size: 0.85em;">' . esc_textarea($value) . '</textarea>';
if (!empty($helpText)) { if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . $helpText . '</div>'; $html .= ' <div class="form-text small">' . $helpText . '</div>';
} }

View File

@@ -26,7 +26,21 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], '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 // Content
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'], 'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui; namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class TopNotificationBarFormBuilder final class TopNotificationBarFormBuilder
{ {
@@ -105,19 +106,73 @@ final class TopNotificationBarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Select: Show on Pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-0 mt-3">'; // Grupo especial: _page_visibility
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">'; // =============================================
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <hr class="my-3">';
$html .= ' Mostrar en'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' </label>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <select id="topBarShowOnPages" class="form-select form-select-sm">'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= ' </p>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$html .= ' </select>'; $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>'; $html .= ' </div>';
@@ -305,4 +360,26 @@ final class TopNotificationBarFormBuilder
return $html; 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 @@
@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; }
}

File diff suppressed because one or more lines are too long

View File

@@ -24,11 +24,12 @@
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, --font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
/* Fuente primaria - Poppins según template y documentación */ /* Fuente primaria - Poppins con fallback ajustado (Fase 4.3 PageSpeed)
--font-primary: 'Poppins', sans-serif; '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 */ /* Fuente para encabezados - Poppins con fallback ajustado */
--font-headings: 'Poppins', sans-serif; --font-headings: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para código (monospace) */ /* Fuente para código (monospace) */
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono',
@@ -56,11 +57,30 @@
Pesos incluidos: 400, 500, 600, 700 Pesos incluidos: 400, 500, 600, 700
Formato: WOFF2 (mejor compresión) 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-face {
font-family: 'Poppins'; font-family: 'Poppins';
src: url('../fonts/poppins-v24-latin-regular.woff2') format('woff2'); src: url('../Fonts/poppins-v24-latin-regular.woff2') format('woff2');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -68,7 +88,7 @@
@font-face { @font-face {
font-family: 'Poppins'; font-family: 'Poppins';
src: url('../fonts/poppins-v24-latin-500.woff2') format('woff2'); src: url('../Fonts/poppins-v24-latin-500.woff2') format('woff2');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -76,7 +96,7 @@
@font-face { @font-face {
font-family: 'Poppins'; font-family: 'Poppins';
src: url('../fonts/poppins-v24-latin-600.woff2') format('woff2'); src: url('../Fonts/poppins-v24-latin-600.woff2') format('woff2');
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -84,7 +104,7 @@
@font-face { @font-face {
font-family: 'Poppins'; font-family: 'Poppins';
src: url('../fonts/poppins-v24-latin-700.woff2') format('woff2'); src: url('../Fonts/poppins-v24-latin-700.woff2') format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;

View File

@@ -63,13 +63,14 @@
/* ======================================== /* ========================================
STYLE 2: Orange Header with Light Background 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) 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) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(3) tr:first-child td { .post-content table:not(.analisis table):nth-of-type(3) tr:first-child td {
background: var(--color-orange-primary); background: var(--color-orange-primary);
color: #ffffff !important; color: var(--color-navy-dark) !important;
border: none !important; border: none !important;
box-shadow: 0 2px 8px rgba(255, 133, 0, 0.3); box-shadow: 0 2px 8px rgba(255, 133, 0, 0.3);
} }
@@ -126,13 +127,14 @@
/* ======================================== /* ========================================
STYLE 5: Orange Gradient Header 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) 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) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(6) 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%); 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; border: none !important;
box-shadow: 0 2px 8px rgba(255, 133, 0, 0.35); box-shadow: 0 2px 8px rgba(255, 133, 0, 0.35);
} }
@@ -168,13 +170,14 @@
/* ======================================== /* ========================================
STYLE 7: Light Orange Background 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) 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) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(8) tr:first-child td { .post-content table:not(.analisis table):nth-of-type(8) tr:first-child td {
background: var(--color-orange-primary); background: var(--color-orange-primary);
color: #ffffff !important; color: var(--color-navy-dark) !important;
border: none !important; border: none !important;
border-bottom: 3px solid var(--color-navy-primary) !important; border-bottom: 3px solid var(--color-navy-primary) !important;
} }
@@ -235,6 +238,7 @@
/* ======================================== /* ========================================
STYLE 10: Bold Orange Border 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) { .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) tbody tr:first-child td,
.post-content table:not(.analisis table):nth-of-type(11) 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%); 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; border: none !important;
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4); box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4);
} }

View File

@@ -246,31 +246,12 @@
font-size: 24px; font-size: 24px;
} }
.container { /* Container width uses CSS variable from Theme Settings */
max-width: 1140px; .container,
} .container-lg,
.container-xl,
.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-xxl { .container-xxl {
max-width: 1700px; max-width: var(--roi-container-width, 1320px);
} }
} }

View File

@@ -88,3 +88,43 @@
.transition-none { .transition-none {
transition: none !important; 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

@@ -28,6 +28,10 @@
border-radius: 8px; border-radius: 8px;
border: none; border: none;
border-spacing: 0; 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 */ /* Eliminar todos los bordes */
@@ -153,12 +157,13 @@
text-align: left; 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:nth-child(3),
.analisis table td.c3, .analisis table td.c3,
.desglose table td.c3 { .desglose table td.c3 {
text-align: center !important; text-align: center !important;
color: #6c757d; color: #495057;
font-size: 0.9em; font-size: 0.9em;
} }
@@ -214,16 +219,17 @@
/* ======================================== /* ========================================
FILAS DE SUBTOTALES FILAS DE SUBTOTALES
(Suma de Material, Suma de Mano de Obra, etc) (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, .analisis table tr.subtotal-row,
.desglose 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, .analisis table tr.subtotal-row td,
.desglose table tr.subtotal-row td { .desglose table tr.subtotal-row td {
font-weight: 700; font-weight: 700;
color: var(--color-orange-primary); color: #1e3a5f;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
border: none !important; border: none !important;
} }
@@ -235,7 +241,7 @@
.analisis table tr.subtotal-row td.c6, .analisis table tr.subtotal-row td.c6,
.analisis table tr.subtotal-row td:nth-child(6) { .analisis table tr.subtotal-row td:nth-child(6) {
font-size: 1.05rem; font-size: 1.05rem;
color: var(--color-orange-primary); color: #1e3a5f;
} }
/* ======================================== /* ========================================

View File

@@ -341,6 +341,11 @@ img {
.content-wrapper { .content-wrapper {
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
} }
/* Full width when no sidebar */
.no-sidebar .content-wrapper {
grid-template-columns: 1fr;
}
} }
#primary { #primary {

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

File diff suppressed because one or more lines are too long

View File

@@ -182,12 +182,74 @@
}, CONFIG.timeout); }, 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 * Inicializa el cargador retrasado de AdSense
*/ */
function init() { 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 // Verificar si el retardo de AdSense está habilitado
if (!window.roidsenseDelayed) { if (!window.roiAdsenseDelayed) {
debugLog('Retardo de AdSense no habilitado'); debugLog('Retardo de AdSense no habilitado');
return; return;
} }

View File

@@ -0,0 +1,135 @@
/**
* TIPO 5: Lazy CSS Loader
*
* Carga CSS no critico despues del evento load usando:
* - requestIdleCallback para CSS de baja prioridad
* - Event listeners para CSS condicional
*
* @package ROITheme
* @since 1.0.20
*/
(function() {
'use strict';
// Configuracion de CSS lazy (inyectada desde PHP)
var config = window.roiLazyCSSConfig || {
baseUrl: '',
version: '1.0.0',
idleTimeout: 2000,
cssFiles: []
};
/**
* Carga un archivo CSS de forma asincrona
*
* @param {string} href URL del archivo CSS
* @param {string} id ID del elemento link
* @returns {Promise}
*/
function loadCSS(href, id) {
// Evitar duplicados
if (document.getElementById(id)) {
return Promise.resolve();
}
return new Promise(function(resolve, reject) {
var link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = href;
link.onload = resolve;
link.onerror = reject;
document.head.appendChild(link);
});
}
/**
* Carga CSS cuando el navegador esta idle
*
* @param {Array} files Lista de archivos a cargar
*/
function loadOnIdle(files) {
var load = function() {
files.forEach(function(file) {
loadCSS(
config.baseUrl + file.path + '?ver=' + config.version,
'roi-lazy-' + file.id
);
});
};
if ('requestIdleCallback' in window) {
requestIdleCallback(load, { timeout: config.idleTimeout });
} else {
// Fallback para Safari
setTimeout(load, config.idleTimeout);
}
}
/**
* Carga CSS de print solo cuando se va a imprimir
*
* @param {Object} file Archivo de print CSS
*/
function setupPrintCSS(file) {
var loaded = false;
var load = function() {
if (loaded) return;
loaded = true;
loadCSS(
config.baseUrl + file.path + '?ver=' + config.version,
'roi-lazy-print'
);
};
// Evento antes de imprimir
window.addEventListener('beforeprint', load);
// Fallback: detectar Ctrl+P / Cmd+P
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
load();
}
});
}
/**
* Inicializacion
*/
function init() {
var idleFiles = [];
var printFile = null;
config.cssFiles.forEach(function(file) {
switch (file.trigger) {
case 'idle':
idleFiles.push(file);
break;
case 'print':
printFile = file;
break;
}
});
// Cargar CSS idle despues de que la pagina este lista
if (idleFiles.length > 0) {
if (document.readyState === 'complete') {
loadOnIdle(idleFiles);
} else {
window.addEventListener('load', function() {
loadOnIdle(idleFiles);
});
}
}
// Configurar CSS de print
if (printFile) {
setupPrintCSS(printFile);
}
}
// Iniciar
init();
})();

27
Assets/Js/main.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* ROI THEME - MAIN JAVASCRIPT
*
* OPTIMIZACIÓN TBT Fase 2.3 (2025-11-27):
* - Eliminado ~300 líneas de código muerto
* - Removido: loadContactModal (modalContainer no existe)
* - Removido: initContactForm (contactForm no existe)
* - Removido: footerContactForm handler (ID incorrecto)
* - Removido: TOC ScrollSpy duplicado (.toc-container no existe)
* - Removido: smooth scroll duplicado (Bootstrap lo maneja)
* - Removido: console.log de debug
*
* Código activo: Solo efecto scroll del navbar
* Reducción: ~315 líneas → ~25 líneas
*/
// Navbar scroll effect - adds 'scrolled' class when user scrolls
window.addEventListener('scroll', function() {
const navbar = document.querySelector('.navbar');
if (navbar) {
if (window.scrollY > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
}
});

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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