52 Commits

Author SHA1 Message Date
FrankZamora
555541b2a0 fix(js): wait for adsbygoogle.js before push scripts
Race condition caused push to execute before library loaded.
Add onload callback to loadAdSenseScripts() function.
2025-12-10 12:44:04 -06:00
FrankZamora
fae4def974 chore(js): enable adsense-loader debug mode temporarily 2025-12-10 12:40:29 -06:00
FrankZamora
8bbbf484bd chore(php): add temporary debug to content ad injector 2025-12-10 12:30:27 -06:00
FrankZamora
50c411408e fix(php): update formbuilder dropdown options for incontent ads
- Extend max ads dropdown from 15 to 25 options
- Add "1 elemento" option to min spacing dropdown
- Fix undefined variable $isLegacy, changed to $isParagraphsOnly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:20:44 -06:00
FrankZamora
d7c9c2a801 feat(schema): expand ad limits and spacing options
- incontent_max_total_ads: add options 16-25
- incontent_min_spacing: add 1 elemento option
2025-12-10 12:14:54 -06:00
FrankZamora
30068ca01e chore(php): remove temporary debug from content ad injector 2025-12-10 12:09:52 -06:00
FrankZamora
959d76fd92 fix(php): rewrite forbidden zones detection with strpos 2025-12-10 11:52:44 -06:00
FrankZamora
04387d46bb fix(php): exclude tables and embeds from ad injection
- Add mapForbiddenZones() for tables, iframes, embeds
- Remove table from scannable elements pattern
- Filter positions inside forbidden zones
- Fixes ads inside table cells and YouTube embeds
2025-12-10 11:44:40 -06:00
FrankZamora
4f1e85fe88 chore(php): remove temporary debug comments from content ad injector 2025-12-10 11:39:36 -06:00
FrankZamora
2cb7363cbb fix(php): support advanced incontent slot names in renderer
The getLocationConfig regex only matched post_content_X but advanced
incontent uses post_content_adv_X format. Added new regex pattern.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:33:53 -06:00
FrankZamora
18bf3d191c chore: add detailed debug for inject advanced
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:29:28 -06:00
FrankZamora
09d87835b8 chore: add debug comment to diagnose mode
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 10:58:48 -06:00
FrankZamora
2896e2d006 feat(php): implement advanced in-content ads with multi-element targeting
- Add incontent_advanced group with 19 configurable fields in schema
- Support 5 density modes: paragraphs_only, conservative, balanced,
  aggressive, custom
- Enable ad placement after H2, H3, paragraphs, images, lists,
  blockquotes, and tables
- Add probability-based selection (25-100%) per element type
- Implement priority-based and position-based ad selection strategies
- Add detailed mode descriptions in admin UI for better UX
- Rename 'legacy' terminology to 'paragraphs_only' for clarity
- Support deterministic randomization using post_id + date seed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:42:53 -06:00
FrankZamora
c2fff49961 docs(config): add advanced incontent ads specification
- proposal.md: define problem and expected changes
- design.md: 9 technical decisions with rationale
- spec.md: complete GIVEN/WHEN/THEN scenarios
- tasks.md: implementation tasks with dependencies

Features specified:
- 7 ad insertion locations (H2, H3, p, img, lists, blockquotes, tables)
- 5 density modes (legacy, conservative, balanced, aggressive, custom)
- 2 selection strategies (position vs priority)
- Deterministic probability with daily seed
- Backward compatibility with legacy fields

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:58:50 -06:00
FrankZamora
85f3387fd2 perf(php): add conditional debug logging to prevent gb logs
- add ROI_DEBUG constant (default false) to control debug output
- create roi_debug_log() function for conditional logging
- replace all error_log DEBUG calls with roi_debug_log
- keep ERROR logs always active for exception tracking
- to enable debug, add define('ROI_DEBUG', true) in wp-config.php

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

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

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

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

Plan 1000.01 implementation.

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

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

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

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

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

Also removes temporary debug logging added for diagnosis.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

También incluye specs OpenSpec para arquitectura del tema.

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

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

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

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

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

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

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

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

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

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

## Solución Plan 99.15 (Clean Architecture)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:37:52 -06:00
195 changed files with 17600 additions and 5685 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.

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"mcp__serena__activate_project",
"mcp__serena__find_symbol",
"Bash(ssh:*)"
]
}
}

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]
}
}

1
.gitignore vendored
View File

@@ -73,5 +73,4 @@ _testing-suite/
# Claude Code tools
.playwright-mcp/
.serena/
.claude/
nul

1
.husky/commit-msg Normal file
View File

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

18
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

View File

@@ -95,6 +95,50 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface
'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'],
// INCONTENT ADVANCED (In-Content Ads Avanzado)
'adsense-placementIncontentMode' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_mode'],
'adsense-placementIncontentAfterH2Enabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h2_enabled'],
'adsense-placementIncontentAfterH2Probability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h2_probability'],
'adsense-placementIncontentAfterH3Enabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h3_enabled'],
'adsense-placementIncontentAfterH3Probability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h3_probability'],
'adsense-placementIncontentAfterParagraphsEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_paragraphs_enabled'],
'adsense-placementIncontentAfterParagraphsProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_paragraphs_probability'],
'adsense-placementIncontentAfterImagesEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_images_enabled'],
'adsense-placementIncontentAfterImagesProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_images_probability'],
'adsense-placementIncontentAfterListsEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_lists_enabled'],
'adsense-placementIncontentAfterListsProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_lists_probability'],
'adsense-placementIncontentAfterBlockquotesEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_blockquotes_enabled'],
'adsense-placementIncontentAfterBlockquotesProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_blockquotes_probability'],
'adsense-placementIncontentAfterTablesEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_tables_enabled'],
'adsense-placementIncontentAfterTablesProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_tables_probability'],
'adsense-placementIncontentMaxTotalAds' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_max_total_ads'],
'adsense-placementIncontentMinSpacing' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_min_spacing'],
'adsense-placementIncontentFormat' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_format'],
'adsense-placementIncontentPriorityMode' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_priority_mode'],
];
}
}

View File

@@ -4,6 +4,7 @@ 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
@@ -46,6 +47,7 @@ final class AdsensePlacementFormBuilder
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildDiagramSection();
$html .= $this->buildPostLocationsGroup($componentId);
$html .= $this->buildInContentAdvancedGroup($componentId);
$html .= $this->buildInContentAdsGroup($componentId);
$html .= $this->buildExclusionsGroup($componentId);
$html .= ' </div>';
@@ -57,6 +59,7 @@ final class AdsensePlacementFormBuilder
$html .= $this->buildRailAdsGroup($componentId);
$html .= $this->buildAnchorAdsGroup($componentId);
$html .= $this->buildVignetteAdsGroup($componentId);
$html .= $this->buildSearchResultsGroup($componentId);
$html .= ' </div>';
$html .= '</div>';
@@ -95,6 +98,47 @@ final class AdsensePlacementFormBuilder
$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>';
@@ -299,6 +343,291 @@ final class AdsensePlacementFormBuilder
return $html;
}
/**
* Seccion avanzada para In-Content Ads con multiples tipos de ubicacion
* Incluye: modo de densidad, ubicaciones por elemento, limites y espaciado
*/
private function buildInContentAdvancedGroup(string $cid): string
{
// Obtener valores actuales del grupo incontent_advanced
$mode = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_mode', 'paragraphs_only');
$mode = is_string($mode) ? $mode : 'paragraphs_only';
// Ubicaciones
$h2Enabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h2_enabled', true);
$h2Prob = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h2_probability', '100');
$h3Enabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h3_enabled', true);
$h3Prob = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h3_probability', '50');
$paragraphsEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_paragraphs_enabled', true);
$paragraphsProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_paragraphs_probability', '75');
$imagesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_images_enabled', true);
$imagesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_images_probability', '75');
$listsEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_lists_enabled', false);
$listsProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_lists_probability', '50');
$blockquotesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_blockquotes_enabled', false);
$blockquotesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_blockquotes_probability', '50');
$tablesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_tables_enabled', false);
$tablesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_tables_probability', '50');
// Limites
$maxAds = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_max_total_ads', '8');
$minSpacing = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_min_spacing', '3');
$format = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_format', 'in-article');
$priorityMode = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_priority_mode', 'position');
// Cast to string where needed
$h2Prob = is_string($h2Prob) ? $h2Prob : '100';
$h3Prob = is_string($h3Prob) ? $h3Prob : '50';
$paragraphsProb = is_string($paragraphsProb) ? $paragraphsProb : '75';
$imagesProb = is_string($imagesProb) ? $imagesProb : '75';
$listsProb = is_string($listsProb) ? $listsProb : '50';
$blockquotesProb = is_string($blockquotesProb) ? $blockquotesProb : '50';
$tablesProb = is_string($tablesProb) ? $tablesProb : '50';
$maxAds = is_string($maxAds) ? $maxAds : '8';
$minSpacing = is_string($minSpacing) ? $minSpacing : '3';
$format = is_string($format) ? $format : 'in-article';
$priorityMode = is_string($priorityMode) ? $priorityMode : 'position';
$isParagraphsOnly = $mode === 'paragraphs_only';
$disabledAttr = $isParagraphsOnly ? 'disabled' : '';
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #198754;">';
$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: #198754;"></i>';
$html .= ' In-Content Ads Avanzado';
$html .= ' <span class="badge bg-success ms-2">Nuevo</span>';
$html .= ' </h5>';
// Indicador de densidad
$html .= ' <div id="roiIncontentDensityIndicator" class="alert alert-info small mb-3">';
$html .= ' <i class="bi bi-speedometer2 me-1"></i>';
$html .= ' Densidad estimada: <strong id="roiDensityLevel">Calculando...</strong>';
$html .= ' <span id="roiDensityBadge" class="badge bg-secondary ms-1">~? ads</span>';
$html .= ' </div>';
// Banner informativo para modo Solo parrafos
$html .= ' <div id="roiParagraphsOnlyBanner" class="alert alert-light border small mb-3' . ($isParagraphsOnly ? '' : ' d-none') . '">';
$html .= ' <i class="bi bi-info-circle me-1 text-primary"></i>';
$html .= ' <strong>Solo parrafos:</strong> Los anuncios se insertan unicamente despues de parrafos, ';
$html .= ' usando la configuracion de la seccion "Post Content". Cambia a otro modo para elegir ubicaciones adicionales.';
$html .= ' </div>';
// Selector de modo con descripciones
$html .= ' <div class="mb-4">';
$html .= ' <label for="' . esc_attr($cid) . 'IncontentMode" class="form-label fw-semibold">';
$html .= ' <i class="bi bi-sliders me-1" style="color: #FF8600;"></i>Estrategia de insercion';
$html .= ' </label>';
$html .= ' <p class="text-muted small mb-2">Define donde y con que frecuencia se insertaran anuncios dentro del contenido.</p>';
$html .= ' <select class="form-select mb-3" id="' . esc_attr($cid) . 'IncontentMode">';
$modeOptions = [
'paragraphs_only' => 'Solo parrafos (clasico)',
'conservative' => 'Conservador - H2 y parrafos',
'balanced' => 'Balanceado - Multiples elementos',
'aggressive' => 'Intensivo - Todos los elementos',
'custom' => 'Personalizado'
];
foreach ($modeOptions as $value => $label) {
$selected = selected($mode, $value, false);
$html .= '<option value="' . esc_attr($value) . '" ' . $selected . '>' . esc_html($label) . '</option>';
}
$html .= ' </select>';
// Descripciones de cada modo
$html .= ' <div id="roiModeDescriptions" class="small">';
// Solo parrafos
$html .= ' <div id="roiModeDescParagraphsOnly" class="alert alert-light border py-2 px-3' . ($mode !== 'paragraphs_only' ? ' d-none' : '') . '">';
$html .= ' <strong class="text-primary"><i class="bi bi-text-paragraph me-1"></i>Solo parrafos</strong>';
$html .= ' <p class="mb-1 mt-1">Inserta anuncios unicamente despues de parrafos. Usa la configuracion de la seccion "Post Content" (numero de anuncios, parrafos entre ads, etc).</p>';
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Tu contenido tiene pocos encabezados o prefieres la configuracion tradicional.</span>';
$html .= ' </div>';
// Conservador
$html .= ' <div id="roiModeDescConservative" class="alert alert-light border py-2 px-3' . ($mode !== 'conservative' ? ' d-none' : '') . '">';
$html .= ' <strong class="text-success"><i class="bi bi-shield-check me-1"></i>Conservador</strong>';
$html .= ' <p class="mb-1 mt-1">Maximo 5 anuncios con espaciado amplio (5 elementos). Solo inserta despues de titulos H2 y parrafos.</p>';
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Priorizas la experiencia del usuario sobre los ingresos. Articulos cortos o medianos.</span>';
$html .= ' </div>';
// Balanceado
$html .= ' <div id="roiModeDescBalanced" class="alert alert-light border py-2 px-3' . ($mode !== 'balanced' ? ' d-none' : '') . '">';
$html .= ' <strong class="text-primary"><i class="bi bi-balance-scale me-1"></i>Balanceado</strong>';
$html .= ' <p class="mb-1 mt-1">Hasta 8 anuncios con espaciado moderado (3 elementos). Usa H2, H3, parrafos e imagenes.</p>';
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Buscas equilibrio entre ingresos y experiencia. Articulos medianos a largos.</span>';
$html .= ' </div>';
// Intensivo
$html .= ' <div id="roiModeDescAggressive" class="alert alert-light border py-2 px-3' . ($mode !== 'aggressive' ? ' d-none' : '') . '">';
$html .= ' <strong class="text-warning"><i class="bi bi-lightning-charge me-1"></i>Intensivo</strong>';
$html .= ' <p class="mb-1 mt-1">Hasta 15 anuncios con espaciado minimo (2 elementos). Usa todos los tipos de elementos disponibles.</p>';
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Priorizas maximizar ingresos. Solo para articulos muy largos (+3000 palabras).</span>';
$html .= ' </div>';
// Personalizado
$html .= ' <div id="roiModeDescCustom" class="alert alert-light border py-2 px-3' . ($mode !== 'custom' ? ' d-none' : '') . '">';
$html .= ' <strong class="text-secondary"><i class="bi bi-gear me-1"></i>Personalizado</strong>';
$html .= ' <p class="mb-1 mt-1">Tu configuras manualmente cada ubicacion, probabilidad y limites.</p>';
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Quieres control total sobre donde aparecen los anuncios.</span>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
// Subseccion: Ubicaciones por elemento
$html .= ' <details class="mb-3 border rounded" id="roiLocationsDetails"' . ($isParagraphsOnly ? '' : ' open') . '>';
$html .= ' <summary class="p-3 bg-light fw-bold" style="cursor: pointer;">';
$html .= ' <i class="bi bi-geo-alt me-1"></i>';
$html .= ' Ubicaciones por Elemento';
$html .= ' </summary>';
$html .= ' <div class="p-3">';
// Grid de ubicaciones
$locations = [
['id' => 'H2', 'label' => 'Despues de H2 (titulos)', 'enabled' => $h2Enabled, 'prob' => $h2Prob, 'icon' => 'bi-type-h2'],
['id' => 'H3', 'label' => 'Despues de H3 (subtitulos)', 'enabled' => $h3Enabled, 'prob' => $h3Prob, 'icon' => 'bi-type-h3'],
['id' => 'Paragraphs', 'label' => 'Despues de parrafos', 'enabled' => $paragraphsEnabled, 'prob' => $paragraphsProb, 'icon' => 'bi-text-paragraph'],
['id' => 'Images', 'label' => 'Despues de imagenes', 'enabled' => $imagesEnabled, 'prob' => $imagesProb, 'icon' => 'bi-image'],
['id' => 'Lists', 'label' => 'Despues de listas', 'enabled' => $listsEnabled, 'prob' => $listsProb, 'icon' => 'bi-list-ul'],
['id' => 'Blockquotes', 'label' => 'Despues de citas', 'enabled' => $blockquotesEnabled, 'prob' => $blockquotesProb, 'icon' => 'bi-quote'],
['id' => 'Tables', 'label' => 'Despues de tablas', 'enabled' => $tablesEnabled, 'prob' => $tablesProb, 'icon' => 'bi-table'],
];
$probOptions = [
'100' => '100%',
'75' => '75%',
'50' => '50%',
'25' => '25%'
];
foreach ($locations as $loc) {
$enabledId = $cid . 'IncontentAfter' . $loc['id'] . 'Enabled';
$probId = $cid . 'IncontentAfter' . $loc['id'] . 'Probability';
$checked = checked($loc['enabled'], true, false);
$html .= ' <div class="row g-2 mb-2 align-items-center">';
$html .= ' <div class="col-7">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input type="checkbox" class="form-check-input roi-incontent-location" ';
$html .= ' id="' . esc_attr($enabledId) . '" ' . $checked . ' ' . $disabledAttr . '>';
$html .= ' <label class="form-check-label small" for="' . esc_attr($enabledId) . '">';
$html .= ' <i class="bi ' . esc_attr($loc['icon']) . ' me-1" style="color: #0d6efd;"></i>';
$html .= ' ' . esc_html($loc['label']);
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="col-5">';
$html .= ' <select class="form-select form-select-sm roi-incontent-prob" ';
$html .= ' id="' . esc_attr($probId) . '" ' . $disabledAttr . '>';
foreach ($probOptions as $pValue => $pLabel) {
$pSelected = selected($loc['prob'], $pValue, false);
$html .= '<option value="' . esc_attr($pValue) . '" ' . $pSelected . '>' . esc_html($pLabel) . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
}
$html .= ' </div>';
$html .= ' </details>';
// Subseccion: Limites y espaciado
$html .= ' <details class="mb-3 border rounded" id="roiLimitsDetails"' . ($isParagraphsOnly ? '' : ' open') . '>';
$html .= ' <summary class="p-3 bg-light fw-bold" style="cursor: pointer;">';
$html .= ' <i class="bi bi-sliders me-1"></i>';
$html .= ' Limites y Espaciado';
$html .= ' </summary>';
$html .= ' <div class="p-3">';
$html .= ' <div class="row g-3">';
// Max total ads
$html .= ' <div class="col-md-6">';
$html .= ' <label for="' . esc_attr($cid) . 'IncontentMaxTotalAds" class="form-label small fw-semibold">';
$html .= ' Maximo total de ads';
$html .= ' </label>';
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentMaxTotalAds" ' . $disabledAttr . '>';
for ($i = 1; $i <= 25; $i++) {
$iStr = (string)$i;
$adSelected = selected($maxAds, $iStr, false);
$label = $i === 1 ? '1 anuncio' : $i . ' anuncios';
$html .= '<option value="' . esc_attr($iStr) . '" ' . $adSelected . '>' . esc_html($label) . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
// Min spacing
$html .= ' <div class="col-md-6">';
$html .= ' <label for="' . esc_attr($cid) . 'IncontentMinSpacing" class="form-label small fw-semibold">';
$html .= ' Espaciado minimo (elementos)';
$html .= ' </label>';
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentMinSpacing" ' . $disabledAttr . '>';
$spacingOptions = [
'1' => '1 elemento',
'2' => '2 elementos',
'3' => '3 elementos',
'4' => '4 elementos',
'5' => '5 elementos',
'6' => '6 elementos'
];
foreach ($spacingOptions as $sValue => $sLabel) {
$sSelected = selected($minSpacing, $sValue, false);
$html .= '<option value="' . esc_attr($sValue) . '" ' . $sSelected . '>' . esc_html($sLabel) . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
// Formato de ads
$html .= ' <div class="col-md-6">';
$html .= ' <label for="' . esc_attr($cid) . 'IncontentFormat" class="form-label small fw-semibold">';
$html .= ' Formato de ads';
$html .= ' </label>';
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentFormat" ' . $disabledAttr . '>';
$formatOptions = [
'in-article' => 'In-Article (fluid)',
'auto' => 'Auto (responsive)'
];
foreach ($formatOptions as $fValue => $fLabel) {
$fSelected = selected($format, $fValue, false);
$html .= '<option value="' . esc_attr($fValue) . '" ' . $fSelected . '>' . esc_html($fLabel) . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
// Priority mode
$html .= ' <div class="col-md-6">';
$html .= ' <label for="' . esc_attr($cid) . 'IncontentPriorityMode" class="form-label small fw-semibold">';
$html .= ' Estrategia de seleccion';
$html .= ' </label>';
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentPriorityMode" ' . $disabledAttr . '>';
$priorityOptions = [
'position' => 'Por posicion (distribucion uniforme)',
'priority' => 'Por prioridad (maximizar H2/H3)'
];
foreach ($priorityOptions as $pmValue => $pmLabel) {
$pmSelected = selected($priorityMode, $pmValue, false);
$html .= '<option value="' . esc_attr($pmValue) . '" ' . $pmSelected . '>' . esc_html($pmLabel) . '</option>';
}
$html .= ' </select>';
$html .= ' <small class="text-muted">Como resolver conflictos cuando dos ubicaciones estan muy cerca</small>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </details>';
// Warning para densidad alta
$html .= ' <div id="roiHighDensityWarning" class="alert alert-warning small d-none">';
$html .= ' <i class="bi bi-exclamation-triangle me-1"></i>';
$html .= ' <strong>Atencion:</strong> Densidad alta (>10 ads) puede afectar UX y violar politicas de AdSense.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Seccion especial para in-content ads con configuracion de 1-8 random
*/
@@ -708,6 +1037,101 @@ final class AdsensePlacementFormBuilder
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;">';
@@ -825,4 +1249,23 @@ final class AdsensePlacementFormBuilder
esc_attr($id), esc_html($label), esc_attr($id), $optionsHtml
);
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
{
$checked = checked($value, true, false);
return sprintf(
'<div class="form-check">
<input class="form-check-input" type="checkbox" id="%s" %s>
<label class="form-check-label small" for="%s">
<i class="bi %s me-1" style="color: #6c757d;"></i>%s
</label>
</div>',
esc_attr($id),
$checked,
esc_attr($id),
esc_attr($icon),
esc_html($label)
);
}
}

View File

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

View File

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

View File

@@ -26,7 +26,19 @@ final class ContactFormFieldMapper implements FieldMapperInterface
'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'contactFormShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'contactFormVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'contactFormVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'contactFormVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'contactFormVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'contactFormExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'contactFormExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'contactFormExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'contactFormExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Contact Form
@@ -93,18 +94,46 @@ final class ContactFormFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="contactFormShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="contactFormShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'contactForm');
$html .= ' </div>';
$html .= '</div>';
@@ -598,4 +627,26 @@ final class ContactFormFormBuilder
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'],
'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'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
'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para el CTA Box Sidebar
@@ -94,18 +95,61 @@ final class CtaBoxSidebarFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
// Obtener valores de _page_visibility (grupo especial)
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
// Grid 3 columnas según Design System
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'cta');
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' <select id="ctaShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
@@ -515,4 +559,29 @@ final class CtaBoxSidebarFormBuilder
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'],
'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'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
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* Class CtaLetsTalkFormBuilder
@@ -120,16 +121,73 @@ final class CtaLetsTalkFormBuilder
$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 .= ' <label for="ctaLetsTalkShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
$html .= ' <select id="ctaLetsTalkShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
@@ -447,4 +505,26 @@ final class CtaLetsTalkFormBuilder
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'],
'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'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
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para CTA Post
@@ -85,17 +86,59 @@ final class CtaPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
// =============================================
// 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 .= ' <label for="ctaPostShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaPostHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaPostHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesion iniciada</small>';
$html .= ' </label>';
$html .= ' <select id="ctaPostShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
@@ -437,4 +480,26 @@ final class CtaPostFormBuilder
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

@@ -8,9 +8,10 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para ID único de snippet CSS
*
* Soporta dos formatos:
* Soporta tres formatos:
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
* 2. Legacy/Migración: kebab-case (ej: "cls-tables-apu", "generic-tables")
* 2. Legacy con prefijo: css_[descriptive]_[number] (ej: "css_tablas_apu_1764624826")
* 3. Legacy kebab-case: (ej: "cls-tables-apu", "generic-tables")
*
* Esto permite migrar snippets existentes sin romper IDs.
*/
@@ -18,6 +19,7 @@ final class SnippetId
{
private const PREFIX = 'css_';
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/';
private const PATTERN_LEGACY_PREFIX = '/^css_[a-z0-9_]+$/';
private const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
private function __construct(
@@ -47,7 +49,8 @@ final class SnippetId
// Validar formato generado (css_*)
if (str_starts_with($id, self::PREFIX)) {
if (!preg_match(self::PATTERN_GENERATED, $id)) {
// Acepta formato nuevo (css_timestamp_random) o legacy (css_descriptivo_numero)
if (!preg_match(self::PATTERN_GENERATED, $id) && !preg_match(self::PATTERN_LEGACY_PREFIX, $id)) {
throw new ValidationException(
sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id)
);

View File

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

View File

@@ -5,12 +5,7 @@ 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
@@ -19,6 +14,9 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
* - Constructor recibe AdminDashboardRenderer
* - Método buildForm() genera el HTML del formulario
*
* NOTA: El handler de formulario POST está en CustomCSSManagerBootstrap
* para que se ejecute en admin_init ANTES de que se envíen headers HTTP.
*
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
*/
final class CustomCSSManagerFormBuilder
@@ -28,120 +26,15 @@ final class CustomCSSManagerFormBuilder
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
// Crear repositorio y Use Case para listar snippets
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);
// NOTA: El handler POST está en CustomCSSManagerBootstrap (admin_init)
}
/**
@@ -160,13 +53,9 @@ final class CustomCSSManagerFormBuilder
// Header
$html .= $this->buildHeader($componentId, count($snippets));
// Mensajes flash
// Toast para mensajes (usa el sistema existente de admin-dashboard.js)
if ($message) {
$html .= sprintf(
'<div class="alert alert-%s m-3">%s</div>',
esc_attr($message['type']),
esc_html($message['text'])
);
$html .= $this->buildToastTrigger($message);
}
// Lista de snippets existentes
@@ -367,7 +256,7 @@ final class CustomCSSManagerFormBuilder
// 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 .= ' <i class="bi bi-check-circle me-1"></i> Guardar Cambios';
$html .= ' </button>';
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
@@ -449,14 +338,84 @@ final class CustomCSSManagerFormBuilder
$message = $_GET['roi_message'] ?? null;
if ($message === 'success') {
return ['type' => 'success', 'text' => 'Snippet guardado correctamente'];
return ['type' => 'success', 'text' => 'Cambios guardados correctamente'];
}
if ($message === 'error') {
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
return ['type' => 'danger', 'text' => $error];
return ['type' => 'error', 'text' => $error];
}
return null;
}
/**
* Genera script para mostrar Toast
*/
private function buildToastTrigger(array $message): string
{
$type = esc_js($message['type']);
$text = esc_js($message['text']);
// Mapear tipo a configuración de Bootstrap
$typeMap = [
'success' => ['bg' => 'success', 'icon' => 'bi-check-circle-fill'],
'error' => ['bg' => 'danger', 'icon' => 'bi-x-circle-fill'],
];
$config = $typeMap[$type] ?? $typeMap['success'];
$bg = $config['bg'];
$icon = $config['icon'];
return <<<HTML
<script>
document.addEventListener('DOMContentLoaded', function() {
// Crear container de toasts si no existe
let toastContainer = document.getElementById('roiToastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'roiToastContainer';
toastContainer.className = 'toast-container position-fixed start-50 translate-middle-x';
toastContainer.style.top = '60px';
toastContainer.style.zIndex = '999999';
document.body.appendChild(toastContainer);
}
// Crear toast
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="\${toastId}" class="toast align-items-center text-white bg-{$bg} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi {$icon} me-2"></i>
<strong>{$text}</strong>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
// Mostrar toast
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Eliminar del DOM después de ocultarse
toastElement.addEventListener('hidden.bs.toast', function() {
toastElement.remove();
});
// Limpiar parámetros de URL sin recargar
const url = new URL(window.location.href);
url.searchParams.delete('roi_message');
url.searchParams.delete('roi_error');
window.history.replaceState({}, '', url.toString());
});
</script>
HTML;
}
}

View File

@@ -26,7 +26,19 @@ final class FeaturedImageFieldMapper implements FieldMapperInterface
'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'featuredImageShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'featuredImageVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'featuredImageVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'featuredImageVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'featuredImageVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'featuredImageExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'featuredImageExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'featuredImageExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'featuredImageExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class FeaturedImageFormBuilder
{
@@ -100,18 +101,46 @@ final class FeaturedImageFormBuilder
$html .= ' </div>';
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="featuredImageShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="featuredImageShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'featuredImage');
$html .= ' </div>';
$html .= '</div>';
@@ -119,6 +148,28 @@ final class FeaturedImageFormBuilder
return $html;
}
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
private function buildContentGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';

View File

@@ -27,6 +27,19 @@ final class FooterFieldMapper implements FieldMapperInterface
'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'footerShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
// Page Visibility (grupo especial _page_visibility)
'footerVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'footerVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'footerVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'footerVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'footerVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'footerExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'footerExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'footerExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'footerExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Widget 1
'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'],
'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Footer
@@ -90,6 +91,47 @@ final class FooterFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'footer');
$html .= ' </div>';
$html .= '</div>';
@@ -410,4 +452,19 @@ final class FooterFormBuilder
}
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,9 +26,21 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'heroVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'heroVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'heroVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'heroVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'heroExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'heroExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'heroExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'heroExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class HeroFormBuilder
{
@@ -102,19 +103,46 @@ final class HeroFormBuilder
$html .= ' </div>';
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="heroShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' </select>';
// =============================================
// 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);
@@ -427,4 +455,26 @@ final class HeroFormBuilder
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

@@ -107,6 +107,15 @@ final class AdminAssetEnqueuer
true
);
// Script de toggle para exclusiones (Plan 99.11)
wp_enqueue_script(
'roi-exclusion-toggle',
$this->themeUri . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js',
['roi-admin-dashboard'],
filemtime(get_template_directory() . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js'),
true
);
// Pasar variables al JavaScript
wp_localize_script(
'roi-admin-dashboard',

View File

@@ -98,6 +98,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'Related Posts',
'icon' => 'bi-grid-3x3-gap',
],
'archive-header' => [
'id' => 'archive-header',
'label' => 'Archive Header',
'icon' => 'bi-layout-text-window',
],
'post-grid' => [
'id' => 'post-grid',
'label' => 'Post Grid',
'icon' => 'bi-grid-3x3',
],
'contact-form' => [
'id' => 'contact-form',
'label' => 'Contact Form',

View File

@@ -518,4 +518,402 @@
});
}
// =========================================================================
// IN-CONTENT ADS AVANZADO - JavaScript
// =========================================================================
document.addEventListener('DOMContentLoaded', function() {
initializeInContentAdvanced();
});
/**
* Inicializa la funcionalidad de In-Content Ads Avanzado
*/
function initializeInContentAdvanced() {
// Buscar el selector de modo (puede tener prefijo dinámico)
const modeSelect = document.querySelector('[id$="IncontentMode"]');
if (!modeSelect) {
return; // No estamos en la página de AdSense
}
// Obtener prefijo del componente desde el ID
const componentPrefix = modeSelect.id.replace('IncontentMode', '');
// Definir presets de modos
const modePresets = {
'paragraphs_only': null, // Solo inserta despues de parrafos (config basica)
'conservative': {
maxAds: '5',
minSpacing: '5',
h2: { enabled: true, prob: '75' },
h3: { enabled: false, prob: '50' },
paragraphs: { enabled: true, prob: '50' },
images: { enabled: false, prob: '50' },
lists: { enabled: false, prob: '50' },
blockquotes: { enabled: false, prob: '50' },
tables: { enabled: false, prob: '50' }
},
'balanced': {
maxAds: '8',
minSpacing: '3',
h2: { enabled: true, prob: '100' },
h3: { enabled: true, prob: '50' },
paragraphs: { enabled: true, prob: '75' },
images: { enabled: true, prob: '75' },
lists: { enabled: false, prob: '50' },
blockquotes: { enabled: false, prob: '50' },
tables: { enabled: false, prob: '50' }
},
'aggressive': {
maxAds: '15',
minSpacing: '2',
h2: { enabled: true, prob: '100' },
h3: { enabled: true, prob: '100' },
paragraphs: { enabled: true, prob: '100' },
images: { enabled: true, prob: '100' },
lists: { enabled: true, prob: '75' },
blockquotes: { enabled: true, prob: '75' },
tables: { enabled: true, prob: '75' }
},
'custom': null // Configuración manual
};
// Elementos del DOM
const elements = {
mode: modeSelect,
paragraphsOnlyBanner: document.getElementById('roiParagraphsOnlyBanner'),
densityIndicator: document.getElementById('roiIncontentDensityIndicator'),
densityLevel: document.getElementById('roiDensityLevel'),
densityBadge: document.getElementById('roiDensityBadge'),
highDensityWarning: document.getElementById('roiHighDensityWarning'),
locationsDetails: document.getElementById('roiLocationsDetails'),
limitsDetails: document.getElementById('roiLimitsDetails'),
maxAds: document.querySelector('[id$="IncontentMaxTotalAds"]'),
minSpacing: document.querySelector('[id$="IncontentMinSpacing"]'),
// Descripciones de modos
modeDescriptions: {
paragraphs_only: document.getElementById('roiModeDescParagraphsOnly'),
conservative: document.getElementById('roiModeDescConservative'),
balanced: document.getElementById('roiModeDescBalanced'),
aggressive: document.getElementById('roiModeDescAggressive'),
custom: document.getElementById('roiModeDescCustom')
},
locations: [
{ key: 'H2', el: document.querySelector('[id$="IncontentAfterH2Enabled"]'), prob: document.querySelector('[id$="IncontentAfterH2Probability"]') },
{ key: 'H3', el: document.querySelector('[id$="IncontentAfterH3Enabled"]'), prob: document.querySelector('[id$="IncontentAfterH3Probability"]') },
{ key: 'Paragraphs', el: document.querySelector('[id$="IncontentAfterParagraphsEnabled"]'), prob: document.querySelector('[id$="IncontentAfterParagraphsProbability"]') },
{ key: 'Images', el: document.querySelector('[id$="IncontentAfterImagesEnabled"]'), prob: document.querySelector('[id$="IncontentAfterImagesProbability"]') },
{ key: 'Lists', el: document.querySelector('[id$="IncontentAfterListsEnabled"]'), prob: document.querySelector('[id$="IncontentAfterListsProbability"]') },
{ key: 'Blockquotes', el: document.querySelector('[id$="IncontentAfterBlockquotesEnabled"]'), prob: document.querySelector('[id$="IncontentAfterBlockquotesProbability"]') },
{ key: 'Tables', el: document.querySelector('[id$="IncontentAfterTablesEnabled"]'), prob: document.querySelector('[id$="IncontentAfterTablesProbability"]') }
]
};
// Estado para detectar cambios manuales
let isApplyingPreset = false;
/**
* Actualiza el indicador de densidad
*/
function updateDensityIndicator() {
const mode = elements.mode.value;
if (mode === 'paragraphs_only') {
elements.densityLevel.textContent = 'Solo parrafos';
elements.densityBadge.textContent = 'clasico';
elements.densityBadge.className = 'badge bg-secondary ms-1';
elements.densityIndicator.className = 'alert alert-light border small mb-3';
elements.highDensityWarning.classList.add('d-none');
return;
}
// Calcular densidad estimada
const maxAds = parseInt(elements.maxAds.value) || 8;
let totalWeight = 0;
let enabledCount = 0;
elements.locations.forEach(loc => {
if (loc.el && loc.el.checked) {
const prob = parseInt(loc.prob.value) || 100;
totalWeight += prob;
enabledCount++;
}
});
const avgProb = enabledCount > 0 ? totalWeight / enabledCount : 0;
const estimatedAds = Math.round((maxAds * avgProb) / 100);
// Determinar nivel
let level, badgeClass, alertClass;
if (estimatedAds <= 3) {
level = 'Baja';
badgeClass = 'bg-success';
alertClass = 'alert-success';
} else if (estimatedAds <= 6) {
level = 'Media';
badgeClass = 'bg-info';
alertClass = 'alert-info';
} else if (estimatedAds <= 10) {
level = 'Alta';
badgeClass = 'bg-warning';
alertClass = 'alert-warning';
} else {
level = 'Muy Alta';
badgeClass = 'bg-danger';
alertClass = 'alert-danger';
}
elements.densityLevel.textContent = level;
elements.densityBadge.textContent = '~' + estimatedAds + ' ads';
elements.densityBadge.className = 'badge ' + badgeClass + ' ms-1';
elements.densityIndicator.className = 'alert ' + alertClass + ' small mb-3';
// Mostrar/ocultar warning de densidad alta
if (estimatedAds > 10) {
elements.highDensityWarning.classList.remove('d-none');
} else {
elements.highDensityWarning.classList.add('d-none');
}
}
/**
* Aplica un preset de modo
*/
function applyPreset(presetName) {
const preset = modePresets[presetName];
if (!preset) return;
isApplyingPreset = true;
// Aplicar max ads y spacing
if (elements.maxAds) elements.maxAds.value = preset.maxAds;
if (elements.minSpacing) elements.minSpacing.value = preset.minSpacing;
// Aplicar ubicaciones
const locationKeys = ['h2', 'h3', 'paragraphs', 'images', 'lists', 'blockquotes', 'tables'];
locationKeys.forEach((key, index) => {
const loc = elements.locations[index];
const presetLoc = preset[key];
if (loc.el && presetLoc) {
loc.el.checked = presetLoc.enabled;
if (loc.prob) loc.prob.value = presetLoc.prob;
}
});
isApplyingPreset = false;
updateDensityIndicator();
}
/**
* Habilita/deshabilita campos según modo
*/
function toggleFieldsState() {
const currentMode = elements.mode.value;
const isParagraphsOnly = currentMode === 'paragraphs_only';
// Toggle details sections
if (elements.locationsDetails) {
if (isParagraphsOnly) {
elements.locationsDetails.removeAttribute('open');
} else {
elements.locationsDetails.setAttribute('open', '');
}
}
if (elements.limitsDetails) {
if (isParagraphsOnly) {
elements.limitsDetails.removeAttribute('open');
} else {
elements.limitsDetails.setAttribute('open', '');
}
}
// Toggle campos
if (elements.maxAds) elements.maxAds.disabled = isParagraphsOnly;
if (elements.minSpacing) elements.minSpacing.disabled = isParagraphsOnly;
elements.locations.forEach(loc => {
if (loc.el) loc.el.disabled = isParagraphsOnly;
if (loc.prob) loc.prob.disabled = isParagraphsOnly;
});
// Toggle banner informativo
if (elements.paragraphsOnlyBanner) {
if (isParagraphsOnly) {
elements.paragraphsOnlyBanner.classList.remove('d-none');
} else {
elements.paragraphsOnlyBanner.classList.add('d-none');
}
}
// Toggle descripciones de modo (mostrar solo la activa)
if (elements.modeDescriptions) {
Object.keys(elements.modeDescriptions).forEach(mode => {
const descEl = elements.modeDescriptions[mode];
if (descEl) {
if (mode === currentMode) {
descEl.classList.remove('d-none');
} else {
descEl.classList.add('d-none');
}
}
});
}
// Actualizar indicador
updateDensityIndicator();
}
/**
* Maneja cambio de modo
*/
function handleModeChange(e) {
const newMode = e.target.value;
const currentMode = e.target.dataset.previousValue || 'paragraphs_only';
// Si cambia de custom a preset, mostrar confirmación
if (currentMode === 'custom' && newMode !== 'custom' && modePresets[newMode]) {
showConfirmModal(
'Cambiar modo',
'Al cambiar a un modo preconfigurado se perderán tus ajustes personalizados. ¿Continuar?',
function() {
applyPreset(newMode);
toggleFieldsState();
e.target.dataset.previousValue = newMode;
},
function() {
// Cancelar: restaurar valor anterior
e.target.value = currentMode;
}
);
return;
}
// Aplicar preset si corresponde
if (modePresets[newMode]) {
applyPreset(newMode);
}
toggleFieldsState();
e.target.dataset.previousValue = newMode;
}
/**
* Maneja cambios en campos (auto-switch a custom)
*/
function handleFieldChange() {
if (isApplyingPreset) return;
const currentMode = elements.mode.value;
if (currentMode !== 'custom' && currentMode !== 'paragraphs_only') {
elements.mode.value = 'custom';
elements.mode.dataset.previousValue = 'custom';
showNotice('info', 'Modo cambiado a "Personalizado" por tus ajustes manuales.');
updateDensityIndicator();
} else {
updateDensityIndicator();
}
}
// Inicializar estado
elements.mode.dataset.previousValue = elements.mode.value;
toggleFieldsState();
updateDensityIndicator();
// Event listeners
elements.mode.addEventListener('change', handleModeChange);
if (elements.maxAds) {
elements.maxAds.addEventListener('change', handleFieldChange);
}
if (elements.minSpacing) {
elements.minSpacing.addEventListener('change', handleFieldChange);
}
elements.locations.forEach(loc => {
if (loc.el) {
loc.el.addEventListener('change', handleFieldChange);
}
if (loc.prob) {
loc.prob.addEventListener('change', handleFieldChange);
}
});
}
/**
* Muestra un modal de confirmación con callback de cancelación
*/
function showConfirmModal(title, message, onConfirm, onCancel) {
// Crear modal si no existe
let modal = document.getElementById('roiConfirmModal');
if (!modal) {
const modalHTML = `
<div class="modal fade" id="roiConfirmModal" tabindex="-1" aria-labelledby="roiConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-bottom: none;">
<h5 class="modal-title text-white" id="roiConfirmModalLabel">
<i class="bi bi-question-circle me-2" style="color: #FF8600;"></i>
<span id="roiConfirmModalTitle">Confirmar</span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="roiConfirmModalBody" style="padding: 2rem;">
Mensaje de confirmación
</div>
<div class="modal-footer" style="border-top: 1px solid #dee2e6; padding: 1rem 1.5rem;">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>
Cancelar
</button>
<button type="button" class="btn text-white" id="roiConfirmModalConfirm" style="background-color: #FF8600; border-color: #FF8600;">
<i class="bi bi-check-circle me-1"></i>
Confirmar
</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
modal = document.getElementById('roiConfirmModal');
}
// Actualizar contenido
document.getElementById('roiConfirmModalTitle').textContent = title;
document.getElementById('roiConfirmModalBody').textContent = message;
// Configurar callback de confirmación
const confirmButton = document.getElementById('roiConfirmModalConfirm');
const newConfirmButton = confirmButton.cloneNode(true);
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
newConfirmButton.addEventListener('click', function() {
const bsModal = bootstrap.Modal.getInstance(modal);
bsModal.hide();
if (typeof onConfirm === 'function') {
onConfirm();
}
});
// Configurar callback de cancelación
if (typeof onCancel === 'function') {
modal.addEventListener('hidden.bs.modal', function handler() {
modal.removeEventListener('hidden.bs.modal', handler);
// Solo llamar onCancel si no fue por confirmación
if (!modal.dataset.confirmed) {
onCancel();
}
delete modal.dataset.confirmed;
});
newConfirmButton.addEventListener('click', function() {
modal.dataset.confirmed = 'true';
});
}
// Mostrar modal
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
}
})();

View File

@@ -37,7 +37,7 @@ final class ComponentGroupRegistry
'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']
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post', 'archive-header', 'post-grid']
],
'ctas-conversion' => [
'label' => __('CTAs & Conversión', 'roi-theme'),

View File

@@ -60,6 +60,12 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
</div>
</div>
<?php
// Componentes con sistema de guardado propio (CRUD de entidades)
$componentsWithOwnSaveSystem = ['custom-css-manager'];
if (!in_array($activeComponent, $componentsWithOwnSaveSystem, true)):
?>
<!-- Botones Globales Save/Cancel -->
<div class="d-flex justify-content-end gap-2 p-3 rounded border mt-4" style="background-color: #f8f9fa; border-color: #e9ecef !important;">
<button type="button" class="btn btn-outline-secondary" id="cancelChanges">
@@ -71,4 +77,5 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
<?php echo esc_html__('Guardar Cambios', 'roi-theme'); ?>
</button>
</div>
<?php endif; ?>
</div>

View File

@@ -26,10 +26,22 @@ final class NavbarFieldMapper implements FieldMapperInterface
'navbarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'navbarShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'navbarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'],
'navbarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'navbarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'navbarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'navbarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'navbarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'navbarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'navbarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'navbarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'navbarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'navbarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Layout
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],
'navbarPaddingVertical' => ['group' => 'layout', 'attribute' => 'padding_vertical'],

View File

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

View File

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

View File

@@ -29,7 +29,19 @@ final class RelatedPostFieldMapper implements FieldMapperInterface
'relatedPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'relatedPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'relatedPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'relatedPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'relatedPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'relatedPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'relatedPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'relatedPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'relatedPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'relatedPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'relatedPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'relatedPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'relatedPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Related Posts
@@ -86,18 +87,46 @@ final class RelatedPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="relatedPostShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="relatedPostShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'relatedPost');
$html .= ' </div>';
$html .= '</div>';
@@ -498,4 +527,26 @@ final class RelatedPostFormBuilder
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

@@ -5,6 +5,7 @@ namespace ROITheme\Admin\Shared\Infrastructure\Api\WordPress;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
use ROITheme\Admin\Shared\Infrastructure\FieldMapping\FieldMapperRegistry;
use ROITheme\Admin\Shared\Infrastructure\Services\ExclusionFieldProcessor;
/**
* Handler para peticiones AJAX del panel de administracion
@@ -73,10 +74,16 @@ final class AdminAjaxHandler
/**
* Mapea settings de field IDs a grupos/atributos
*
* Soporta tipos especiales para campos de exclusion:
* - json_array: Convierte "a, b, c" a ["a", "b", "c"]
* - json_array_int: Convierte "1, 2, 3" a [1, 2, 3]
* - json_array_lines: Convierte lineas a array
*/
private function mapSettings(array $settings, array $fieldMapping): array
{
$mappedSettings = [];
$fieldProcessor = new ExclusionFieldProcessor();
foreach ($settings as $fieldId => $value) {
if (!isset($fieldMapping[$fieldId])) {
@@ -86,11 +93,17 @@ final class AdminAjaxHandler
$mapping = $fieldMapping[$fieldId];
$groupName = $mapping['group'];
$attributeName = $mapping['attribute'];
$type = $mapping['type'] ?? null;
if (!isset($mappedSettings[$groupName])) {
$mappedSettings[$groupName] = [];
}
// Procesar valor segun tipo
if ($type !== null && is_string($value)) {
$value = $fieldProcessor->process($value, $type);
}
$mappedSettings[$groupName][$attributeName] = $value;
}

View File

@@ -33,6 +33,8 @@ final class FieldMapperProvider
'Footer',
'ThemeSettings',
'AdsensePlacement',
'ArchiveHeader',
'PostGrid',
];
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'],
'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'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
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para Social Share
@@ -94,19 +95,46 @@ final class SocialShareFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="socialShareShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="socialShareShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'socialShare');
$html .= ' </div>';
$html .= '</div>';
@@ -526,4 +554,26 @@ final class SocialShareFormBuilder
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'],
'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'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
'tocTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/**
* FormBuilder para la Tabla de Contenido
@@ -94,19 +95,46 @@ final class TableOfContentsFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="tocShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="tocShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
$html .= ' </select>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'toc');
$html .= ' </div>';
$html .= '</div>';
@@ -585,4 +613,26 @@ final class TableOfContentsFormBuilder
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,8 +26,21 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
'topBarHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'topBarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'topBarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'topBarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'topBarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'topBarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'topBarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'topBarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'topBarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class TopNotificationBarFormBuilder
{
@@ -105,24 +106,50 @@ final class TopNotificationBarFormBuilder
$html .= ' </div>';
$html .= ' </div>';
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
$html .= ' </label>';
$html .= ' <select id="topBarShowOnPages" class="form-select form-select-sm">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' </select>';
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'topBar');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0 mt-3">';
$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) . '>';
@@ -134,6 +161,20 @@ final class TopNotificationBarFormBuilder
$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>';
@@ -319,4 +360,26 @@ final class TopNotificationBarFormBuilder
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

@@ -5,7 +5,7 @@
* NO contiene CSS personalizado (ese va en critical-custom-temp.css - TIPO 3).
*
* Componentes Bootstrap incluidos:
* - System Fonts (CERO flash - sin @font-face externos)
* - Fonts (@font-face Poppins)
* - Variables CSS (:root)
* - Resets (box-sizing, body)
* - Container system
@@ -30,29 +30,45 @@
*/
/* ==========================================================================
SYSTEM FONTS - CERO Flash (sin fuentes externas)
CRITICAL FONTS (Poppins - LCP optimization)
Usa fuentes nativas del sistema operativo:
- macOS/iOS: -apple-system, BlinkMacSystemFont
- Windows: Segoe UI
- Android: Roboto
- Linux: Ubuntu/Cantarell
- Fallback: sans-serif
VENTAJAS:
- 0 KB descarga (fuentes ya instaladas)
- 0 flash/parpadeo (disponibles instantaneamente)
- Mejor rendimiento LCP/FCP
- Familiar para usuarios (fuentes nativas)
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 {
/* System Font Stack - CERO flash garantizado */
--font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", "Liberation Sans",
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-primary: var(--font-system);
--bs-body-font-family: var(--font-system);
/* 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;
@@ -372,7 +388,7 @@ button:focus:not(:focus-visible) {
--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: relative;
/* position: controlado por CriticalCSSService según sticky_enabled */
display: flex;
flex-wrap: wrap;
align-items: center;

View File

@@ -2,12 +2,10 @@
* Sistema de Tipografías - ROI Theme
*
* RESPONSABILIDAD: SOLO definición de fuentes y variables tipográficas
* - Declaraciones @font-face (comentadas - usar Google Fonts)
* - Variables CSS de tipografía (:root)
* - Clases utilitarias de fuentes
*
* NOTA: Usando SYSTEM FONTS para CERO flash/parpadeo
* Las fuentes del sistema están disponibles instantáneamente.
*
* NO debe contener:
* - Estilos de body (van en style.css)
* - Estilos de elementos HTML (van en style.css)
@@ -18,20 +16,20 @@
*/
/* ============================================
SYSTEM FONTS - CERO Flash
SYSTEM FONTS (Por defecto - Recomendado)
============================================ */
:root {
/* Stack de fuentes del sistema - disponibles instantáneamente */
/* Stack de fuentes del sistema - Fallback */
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans',
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
/* Fuente primaria - System fonts (CERO flash) */
--font-primary: var(--font-system);
/* Fuente primaria - Poppins con fallback ajustado (Fase 4.3 PageSpeed)
'Poppins Fallback' tiene size-adjust para reducir CLS durante font swap */
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para encabezados - System fonts */
--font-headings: var(--font-system);
/* Fuente para encabezados - Poppins con fallback ajustado */
--font-headings: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para código (monospace) */
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono',
@@ -48,22 +46,70 @@
*/
/* ============================================
POPPINS - DESHABILITADO
POPPINS (Self-hosted)
============================================
Las @font-face de Poppins fueron eliminadas para
garantizar CERO flash/parpadeo en la carga de página.
Fuentes Poppins alojadas localmente para:
- Eliminar dependencia de Google Fonts
- Mejorar rendimiento (sin requests externos)
- Cumplimiento GDPR (sin tracking de Google)
El sitio ahora usa fuentes del sistema (--font-system)
que están disponibles instantáneamente en todos los
dispositivos sin necesidad de descarga.
Pesos incluidos: 400, 500, 600, 700
Formato: WOFF2 (mejor compresión)
Para reactivar Poppins en el futuro, descomentar las
declaraciones @font-face y actualizar las variables
--font-primary y --font-headings.
Fase 4.3 PageSpeed: Fallback con size-adjust para reducir CLS
- size-adjust: 100.6% ajustado para coincidir mejor con Poppins
- font-display: swap + preload = carga rapida sin salto visual
- Preload en CriticalCSSInjector P:-2 acelera descarga de fuentes
NOTA: El valor 100.6% fue calibrado empiricamente.
- 106% causaba un salto visual notable (navbar se "achicaba")
- 100.6% minimiza el CLS manteniendo legibilidad del fallback
============================================ */
/* Fallback font con metricas ajustadas para Poppins */
@font-face {
font-family: 'Poppins Fallback';
src: local('Arial'), local('Helvetica Neue'), local('Helvetica'), local('sans-serif');
size-adjust: 106%;
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../Fonts/poppins-v24-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ============================================
UTILIDADES DE FUENTES
============================================ */

View File

@@ -12,7 +12,7 @@
BASE STYLES - Todas las tablas genéricas
======================================== */
.post-content table:not(.analisis table) {
.post-content table:not(.analisis table):not(.desglose table) {
width: 100%;
border-collapse: collapse;
margin: 2rem auto;
@@ -23,9 +23,9 @@
}
/* Header styles - VERY OBVIOUS */
.post-content table:not(.analisis table) thead tr:first-child th,
.post-content table:not(.analisis table) tbody tr:first-child td,
.post-content table:not(.analisis table) tr:first-child td {
.post-content table:not(.analisis table):not(.desglose table) thead tr:first-child th,
.post-content table:not(.analisis table):not(.desglose table) tbody tr:first-child td,
.post-content table:not(.analisis table):not(.desglose table) tr:first-child td {
font-weight: 700;
text-align: center;
padding: 1.25rem 1rem;
@@ -34,7 +34,7 @@
}
/* Body cells */
.post-content table:not(.analisis table) tbody tr:not(:first-child) td {
.post-content table:not(.analisis table):not(.desglose table) tbody tr:not(:first-child) td {
padding: 0.875rem 1rem;
border: 1px solid var(--color-neutral-100);
text-align: left;

View File

@@ -88,3 +88,43 @@
.transition-none {
transition: none !important;
}
/* ========================================
COMPONENT VISIBILITY FAILSAFE (Plan 99.15)
CSS failsafe: Oculta wrappers de componentes
cuando body tiene clases roi-hide-*
Estas clases se agregan via BodyClassHooksRegistrar
cuando los componentes están deshabilitados/excluidos.
======================================== */
/* Navbar hidden */
body.roi-hide-navbar .navbar {
display: none !important;
}
/* Table of Contents hidden */
body.roi-hide-toc .roi-toc-container {
display: none !important;
}
/* CTA Sidebar hidden */
body.roi-hide-cta-sidebar .roi-cta-box {
display: none !important;
}
/* Generic sidebar hidden */
body.roi-hide-sidebar .sidebar-sticky {
display: none !important;
}
/* When ALL sidebar components are hidden, expand main column */
body.roi-sidebar-empty .col-lg-9 {
flex: 0 0 100% !important;
max-width: 100% !important;
}
body.roi-sidebar-empty .col-lg-3 {
display: none !important;
}

View File

@@ -15,7 +15,7 @@
const CONFIG = {
timeout: 5000, // Timeout de fallback en milisegundos
loadedClass: 'adsense-loaded',
debug: false // Cambiar a true para logs en consola
debug: true // TEMPORAL: Habilitado para diagnóstico
};
// Estado
@@ -54,8 +54,10 @@
// Remover event listeners para prevenir múltiples triggers
removeEventListeners();
// Cargar etiquetas de script de AdSense
loadAdSenseScripts();
// Cargar etiquetas de script de AdSense y esperar a que cargue
// IMPORTANTE: Debe esperar a que adsbygoogle.js cargue antes de ejecutar push
loadAdSenseScripts(function() {
debugLog('Biblioteca AdSense cargada, ejecutando push scripts...');
// Ejecutar scripts de push de AdSense
executeAdSensePushScripts();
@@ -64,21 +66,30 @@
document.body.classList.add(CONFIG.loadedClass);
debugLog('Carga de AdSense completada');
});
}
/**
* Encuentra y carga todas las etiquetas de script de AdSense retrasadas
* @param {Function} callback - Función a ejecutar cuando la biblioteca cargue
*/
function loadAdSenseScripts() {
function loadAdSenseScripts(callback) {
const delayedScripts = document.querySelectorAll('script[data-adsense-script]');
if (delayedScripts.length === 0) {
debugLog('No se encontraron scripts retrasados de AdSense');
// Ejecutar callback de todas formas (puede haber ads sin script principal)
if (typeof callback === 'function') {
callback();
}
return;
}
debugLog('Se encontraron ' + delayedScripts.length + ' script(s) retrasado(s) de AdSense');
var scriptsLoaded = 0;
var totalScripts = delayedScripts.length;
delayedScripts.forEach(function(oldScript) {
const newScript = document.createElement('script');
@@ -95,6 +106,23 @@
newScript.crossorigin = oldScript.getAttribute('crossorigin');
}
// Esperar a que cargue antes de ejecutar callback
newScript.onload = function() {
scriptsLoaded++;
debugLog('Script cargado (' + scriptsLoaded + '/' + totalScripts + '): ' + newScript.src.substring(0, 50) + '...');
if (scriptsLoaded === totalScripts && typeof callback === 'function') {
callback();
}
};
newScript.onerror = function() {
scriptsLoaded++;
debugLog('Error cargando script: ' + newScript.src);
if (scriptsLoaded === totalScripts && typeof callback === 'function') {
callback();
}
};
// Reemplazar script viejo con el nuevo
oldScript.parentNode.replaceChild(newScript, oldScript);
});
@@ -182,10 +210,72 @@
}, CONFIG.timeout);
}
/**
* Activa slots de AdSense insertados dinamicamente
* Escucha el evento 'roi-adsense-activate' disparado por otros scripts
*/
function setupDynamicAdsListener() {
window.addEventListener('roi-adsense-activate', function() {
debugLog('Evento roi-adsense-activate recibido');
// Si AdSense aun no ha cargado, forzar carga ahora
if (!adsenseLoaded) {
debugLog('AdSense no cargado, forzando carga...');
loadAdSense();
return;
}
// AdSense ya cargado - activar nuevos slots
debugLog('Activando nuevos slots dinamicos...');
activateDynamicSlots();
});
}
/**
* Activa slots de AdSense que fueron insertados despues de la carga inicial
*/
function activateDynamicSlots() {
// Buscar scripts de push que aun no han sido ejecutados
var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]');
if (pendingPushScripts.length === 0) {
debugLog('No hay slots pendientes por activar');
return;
}
debugLog('Activando ' + pendingPushScripts.length + ' slot(s) dinamico(s)');
// Asegurar que adsbygoogle existe
window.adsbygoogle = window.adsbygoogle || [];
pendingPushScripts.forEach(function(oldScript) {
try {
// Crear nuevo script ejecutable
var newScript = document.createElement('script');
newScript.type = 'text/javascript';
newScript.innerHTML = oldScript.innerHTML;
// Reemplazar el placeholder con el script real
oldScript.parentNode.replaceChild(newScript, oldScript);
} catch (e) {
debugLog('Error activando slot: ' + e.message);
}
});
}
/**
* Inicializa el cargador retrasado de AdSense
*/
function init() {
// =========================================================================
// NUEVO: Siempre configurar listener para ads dinamicos
// IMPORTANTE: Esto debe ejecutarse ANTES del early return
// porque los ads dinamicos pueden necesitar activarse aunque
// el delay global este deshabilitado
// =========================================================================
setupDynamicAdsListener();
debugLog('Listener para ads dinamicos configurado');
// Verificar si el retardo de AdSense está habilitado
if (!window.roiAdsenseDelayed) {
debugLog('Retardo de AdSense no habilitado');

View File

@@ -1,99 +0,0 @@
/**
* Auto-detectar y agregar clases a filas especiales de tablas APU
*
* Este script detecta automáticamente filas especiales en tablas .desglose y .analisis
* y les agrega las clases CSS correspondientes para que se apliquen los estilos correctos.
*
* Detecta:
* - Section headers: Material, Mano de Obra, Herramienta, Equipo
* - Subtotal rows: Filas que empiezan con "Suma de"
* - Total row: Costo Directo
*
* @package Apus_Theme
* @since 1.0.0
*/
(function() {
'use strict';
/**
* Agrega clases a filas especiales de tablas APU
*/
function applyApuTableClasses() {
// Buscar todas las tablas con clase .desglose o .analisis
const tables = document.querySelectorAll('.desglose table, .analisis table');
if (tables.length === 0) {
return; // No hay tablas APU en esta página
}
let classesAdded = 0;
tables.forEach(function(table) {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(function(row) {
// Evitar procesar filas que ya tienen clase
if (row.classList.contains('section-header') ||
row.classList.contains('subtotal-row') ||
row.classList.contains('total-row')) {
return;
}
const secondCell = row.querySelector('td:nth-child(2)');
if (!secondCell) {
return; // Fila sin segunda celda
}
const text = secondCell.textContent.trim();
// Detectar section headers
if (text === 'Material' ||
text === 'Mano de Obra' ||
text === 'Herramienta' ||
text === 'Equipo' ||
text === 'MATERIAL' ||
text === 'MANO DE OBRA' ||
text === 'HERRAMIENTA' ||
text === 'EQUIPO') {
row.classList.add('section-header');
classesAdded++;
return;
}
// Detectar subtotales (cualquier variación de "Suma de")
if (text.toLowerCase().startsWith('suma de ') ||
text.toLowerCase().startsWith('subtotal ')) {
row.classList.add('subtotal-row');
classesAdded++;
return;
}
// Detectar total final
if (text === 'Costo Directo' ||
text === 'COSTO DIRECTO' ||
text === 'Total' ||
text === 'TOTAL' ||
text === 'Costo directo') {
row.classList.add('total-row');
classesAdded++;
return;
}
});
});
// Log para debugging (solo en desarrollo)
if (classesAdded > 0 && window.console) {
console.log('[APU Tables] Clases agregadas automáticamente: ' + classesAdded);
}
}
// Ejecutar cuando el DOM esté listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyApuTableClasses);
} else {
// DOM ya está listo
applyApuTableClasses();
}
})();

View File

@@ -1,342 +0,0 @@
/**
* Header Navigation JavaScript
*
* This file handles:
* - Mobile hamburger menu toggle
* - Sticky header behavior
* - Smooth scroll to anchors (optional)
* - Accessibility features (keyboard navigation, ARIA attributes)
* - Body scroll locking when mobile menu is open
*
* @package ROI_Theme
* @since 1.0.0
*/
(function() {
'use strict';
/**
* Initialize on DOM ready
*/
function init() {
setupMobileMenu();
setupStickyHeader();
setupSmoothScroll();
setupKeyboardNavigation();
}
/**
* Mobile Menu Functionality
*/
function setupMobileMenu() {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
const mobileMenuClose = document.getElementById('mobile-menu-close');
if (!mobileMenuToggle || !mobileMenu || !mobileMenuOverlay) {
return;
}
// Open mobile menu
mobileMenuToggle.addEventListener('click', function() {
openMobileMenu();
});
// Close mobile menu via close button
if (mobileMenuClose) {
mobileMenuClose.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu via overlay click
mobileMenuOverlay.addEventListener('click', function() {
closeMobileMenu();
});
// Close mobile menu on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && mobileMenu.classList.contains('active')) {
closeMobileMenu();
mobileMenuToggle.focus();
}
});
// Close mobile menu when clicking a menu link
const mobileMenuLinks = mobileMenu.querySelectorAll('a');
mobileMenuLinks.forEach(function(link) {
link.addEventListener('click', function() {
closeMobileMenu();
});
});
// Handle window resize - close mobile menu if switching to desktop
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
if (window.innerWidth >= 768 && mobileMenu.classList.contains('active')) {
closeMobileMenu();
}
}, 250);
});
}
/**
* Open mobile menu
*/
function openMobileMenu() {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
// Add active classes
mobileMenu.classList.add('active');
mobileMenuOverlay.classList.add('active');
document.body.classList.add('mobile-menu-open');
// Update ARIA attributes
mobileMenuToggle.setAttribute('aria-expanded', 'true');
mobileMenu.setAttribute('aria-hidden', 'false');
mobileMenuOverlay.setAttribute('aria-hidden', 'false');
// Focus trap - focus first menu item
const firstMenuItem = mobileMenu.querySelector('a');
if (firstMenuItem) {
setTimeout(function() {
firstMenuItem.focus();
}, 300);
}
}
/**
* Close mobile menu
*/
function closeMobileMenu() {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
// Remove active classes
mobileMenu.classList.remove('active');
mobileMenuOverlay.classList.remove('active');
document.body.classList.remove('mobile-menu-open');
// Update ARIA attributes
mobileMenuToggle.setAttribute('aria-expanded', 'false');
mobileMenu.setAttribute('aria-hidden', 'true');
mobileMenuOverlay.setAttribute('aria-hidden', 'true');
}
/**
* Sticky Header Behavior
*/
function setupStickyHeader() {
const header = document.getElementById('masthead');
if (!header) {
return;
}
let lastScrollTop = 0;
let scrollThreshold = 100;
window.addEventListener('scroll', function() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// Add/remove scrolled class based on scroll position
if (scrollTop > scrollThreshold) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
lastScrollTop = scrollTop;
}, { passive: true });
}
/**
* Smooth Scroll to Anchors (Optional)
*/
function setupSmoothScroll() {
// Check if user prefers reduced motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
return;
}
// Get all anchor links
const anchorLinks = document.querySelectorAll('a[href^="#"]');
anchorLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
const href = this.getAttribute('href');
// Skip if href is just "#"
if (href === '#') {
return;
}
const target = document.querySelector(href);
if (target) {
e.preventDefault();
// Get header height for offset
const header = document.getElementById('masthead');
const headerHeight = header ? header.offsetHeight : 0;
const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - headerHeight - 20;
window.scrollTo({
top: targetPosition,
behavior: prefersReducedMotion ? 'auto' : 'smooth'
});
// Update URL hash
if (history.pushState) {
history.pushState(null, null, href);
}
// Focus target element for accessibility
target.setAttribute('tabindex', '-1');
target.focus();
}
});
});
}
/**
* Keyboard Navigation for Menus
*/
function setupKeyboardNavigation() {
const menuItems = document.querySelectorAll('.primary-menu > li, .mobile-primary-menu > li');
menuItems.forEach(function(item) {
const link = item.querySelector('a');
const submenu = item.querySelector('.sub-menu');
if (!link || !submenu) {
return;
}
// Open submenu on Enter/Space
link.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
if (submenu) {
e.preventDefault();
toggleSubmenu(item, submenu);
}
}
// Close submenu on Escape
if (e.key === 'Escape') {
closeSubmenu(item, submenu);
link.focus();
}
});
// Close submenu when focus leaves
const submenuLinks = submenu.querySelectorAll('a');
if (submenuLinks.length > 0) {
const lastSubmenuLink = submenuLinks[submenuLinks.length - 1];
lastSubmenuLink.addEventListener('keydown', function(e) {
if (e.key === 'Tab' && !e.shiftKey) {
closeSubmenu(item, submenu);
}
});
}
});
}
/**
* Toggle submenu visibility
*/
function toggleSubmenu(item, submenu) {
const isExpanded = item.classList.contains('submenu-open');
if (isExpanded) {
closeSubmenu(item, submenu);
} else {
openSubmenu(item, submenu);
}
}
/**
* Open submenu
*/
function openSubmenu(item, submenu) {
item.classList.add('submenu-open');
submenu.setAttribute('aria-hidden', 'false');
const firstLink = submenu.querySelector('a');
if (firstLink) {
firstLink.focus();
}
}
/**
* Close submenu
*/
function closeSubmenu(item, submenu) {
item.classList.remove('submenu-open');
submenu.setAttribute('aria-hidden', 'true');
}
/**
* Trap focus within mobile menu when open
*/
function setupFocusTrap() {
const mobileMenu = document.getElementById('mobile-menu');
if (!mobileMenu) {
return;
}
document.addEventListener('keydown', function(e) {
if (!mobileMenu.classList.contains('active')) {
return;
}
if (e.key === 'Tab') {
const focusableElements = mobileMenu.querySelectorAll(
'a, button, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
});
}
/**
* Initialize focus trap
*/
setupFocusTrap();
/**
* Initialize when DOM is ready
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,9 @@ if (!defined('ABSPATH')) {
exit;
}
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/**
* Renderiza un slot de anuncio en una ubicacion
*
@@ -47,16 +50,21 @@ function roi_render_ad_slot(string $location): string
return '';
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return '';
}
// Verificar exclusiones
// Verificar exclusiones legacy (forms group)
if (roi_is_ad_excluded($settings)) {
return '';
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return '';
}
// Obtener renderer desde DIContainer (DIP compliant)
$renderer = $container->getAdsensePlacementRenderer();
@@ -72,17 +80,17 @@ function roi_render_ad_slot(string $location): string
/**
* Verifica si se deben ocultar anuncios para usuarios logueados
*
* @deprecated Plan 99.16: Usar UserVisibilityHelper::shouldShowForUser() en su lugar.
* Esta función se mantiene para compatibilidad hacia atrás.
*
* @param array $settings Configuración del componente
* @return bool true si se debe ocultar, false si se debe mostrar
*/
function roi_should_hide_for_logged_in(array $settings): bool
{
// Si la opcion esta activada Y el usuario esta logueado, ocultar ads
$hideForLoggedIn = $settings['visibility']['hide_for_logged_in'] ?? false;
if ($hideForLoggedIn && is_user_logged_in()) {
return true;
}
return false;
// Delegar a UserVisibilityHelper (Plan 99.16)
return !UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? []);
}
/**
@@ -138,16 +146,21 @@ function roi_render_rail_ads(): string
return '';
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return '';
}
// Verificar exclusiones
// Verificar exclusiones legacy (forms group)
if (roi_is_ad_excluded($settings)) {
return '';
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return '';
}
// Obtener renderer desde DIContainer (DIP compliant)
$renderer = $container->getAdsensePlacementRenderer();
@@ -188,8 +201,13 @@ function roi_enqueue_adsense_script(): void
return;
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return;
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return;
}
@@ -241,16 +259,21 @@ function roi_inject_content_ads(string $content): string
return $content;
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return $content;
}
// Verificar exclusiones
// Verificar exclusiones legacy (forms group)
if (roi_is_ad_excluded($settings)) {
return $content;
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return $content;
}
$renderer = $container->getAdsensePlacementRenderer();
// Inyectar anuncio al inicio (post-top)
@@ -441,16 +464,21 @@ function roi_render_anchor_ads(): string
return '';
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return '';
}
// Verificar exclusiones
// Verificar exclusiones legacy (forms group)
if (roi_is_ad_excluded($settings)) {
return '';
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return '';
}
// Obtener renderer desde DIContainer (DIP compliant)
$renderer = $container->getAdsensePlacementRenderer();
@@ -485,16 +513,21 @@ function roi_render_vignette_ad(): string
return '';
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return '';
}
// Verificar exclusiones
// Verificar exclusiones legacy (forms group)
if (roi_is_ad_excluded($settings)) {
return '';
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return '';
}
// Obtener renderer desde DIContainer (DIP compliant)
$renderer = $container->getAdsensePlacementRenderer();
@@ -551,8 +584,13 @@ function roi_enqueue_anchor_vignette_scripts(): void
return;
}
// Verificar si ocultar para usuarios logueados
if (roi_should_hide_for_logged_in($settings)) {
// Verificar visibilidad por usuario logueado (Plan 99.16)
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
return;
}
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return;
}

View File

@@ -41,7 +41,7 @@ define('ROI_DEFERRED_CSS', [
'roi-utilities',
'roi-accessibility',
'roi-responsive',
'bootstrap-icons',
// NOTA: bootstrap-icons REMOVIDO de diferido - ahora crítico para evitar flash
]);
/**
@@ -125,19 +125,19 @@ function roi_enqueue_bootstrap() {
'roi-bootstrap',
get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css',
array('roi-fonts'),
'5.3.2-subset',
'5.3.2-subset-2', // v2: removed position:relative from .navbar
'print' // DIFERIDO - critical CSS inline evita CLS
);
// Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed)
// Original: 211 KB (2050 iconos) -> Subset: 13 KB (104 iconos) = 94% reduccion
// DIFERIDO: Fase 4.3 - no crítico para renderizado inicial
// CRITICO: Carga inmediata para evitar flash de iconos (4.4KB)
wp_enqueue_style(
'bootstrap-icons',
get_template_directory_uri() . '/Assets/Vendor/bootstrap-icons-subset.min.css',
array('roi-bootstrap'),
ROI_VERSION,
'print'
'all' // CRITICO - no diferir para evitar parpadeo de iconos
);
// Variables CSS del Template RDash - DIFERIDO

View File

@@ -50,6 +50,13 @@ function roi_get_featured_image($post_id = null, $size = 'roi-featured-large', $
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista, no solo el attachment ID
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return ''; // Archivo no existe en servidor
}
// Obtener tipo de post
$post_type = get_post_type($post_id);
@@ -145,6 +152,13 @@ function roi_get_post_thumbnail($post_id = null, $with_link = true) {
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return '';
}
// Obtener la imagen con clases Bootstrap
$image = get_the_post_thumbnail($post_id, 'roi-featured-medium', array(
'class' => 'img-fluid post-thumbnail',
@@ -216,6 +230,13 @@ function roi_get_post_thumbnail_small($post_id = null, $with_link = true) {
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return '';
}
// Obtener la imagen
$image = get_the_post_thumbnail($post_id, 'roi-thumbnail', array(
'class' => 'img-fluid post-thumbnail-small',
@@ -287,6 +308,13 @@ function roi_should_show_featured_image($post_id = null) {
return false;
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return false;
}
// Obtener tipo de post
$post_type = get_post_type($post_id);
@@ -338,6 +366,13 @@ function roi_get_featured_image_url($post_id = null, $size = 'roi-featured-large
return ''; // No placeholder - retornar vacío
}
// Verificar que el archivo físico exista
$thumbnailId = get_post_thumbnail_id($post_id);
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return '';
}
// Obtener URL de la imagen
$image_url = get_the_post_thumbnail_url($post_id, $size);

View File

@@ -552,30 +552,22 @@ add_filter( 'wp_lazy_loading_enabled', 'roi_enable_image_dimensions' );
/**
* Optimizar buffer de salida HTML
*
* Habilita compresión GZIP si está disponible y no está ya habilitada.
* DESACTIVADO: Esta función causa conflicto con W3 Total Cache.
* Cuando zlib.output_compression está activo, W3TC no puede cachear
* las páginas porque recibe "Response is compressed".
*
* La compresión GZIP la maneja nginx a nivel de servidor.
*
* @since 1.0.0
* @deprecated 1.0.1 Conflicto con W3TC page cache - Issue #XX
*/
function roi_enable_gzip_compression() {
// Solo en frontend
if ( is_admin() ) {
// DESACTIVADO - No hacer nada
// La compresión la maneja nginx, no PHP
return;
}
// Verificar si GZIP ya está habilitado
if ( ! ini_get( 'zlib.output_compression' ) && 'ob_gzhandler' !== ini_get( 'output_handler' ) ) {
// Verificar si la extensión está disponible
if ( function_exists( 'gzencode' ) && extension_loaded( 'zlib' ) ) {
// Verificar headers
if ( ! headers_sent() ) {
// Habilitar compresión
ini_set( 'zlib.output_compression', 'On' );
ini_set( 'zlib.output_compression_level', '6' ); // Balance entre compresión y CPU
}
}
}
}
add_action( 'template_redirect', 'roi_enable_gzip_compression', 0 );
// DESACTIVADO - Conflicto con W3 Total Cache page cache
// add_action( 'template_redirect', 'roi_enable_gzip_compression', 0 );
/**
* ============================================================================

View File

@@ -1,294 +0,0 @@
<?php
/**
* Related Posts Functionality
*
* Provides configurable related posts functionality with Bootstrap grid support.
*
* @package ROI_Theme
* @since 1.0.0
*/
// Exit if accessed directly
if (!defined('ABSPATH')) {
exit;
}
/**
* Get related posts based on categories
*
* @param int $post_id The post ID to get related posts for
* @return WP_Query|false Query object with related posts or false if none found
*/
function roi_get_related_posts($post_id) {
// Get post categories
$categories = wp_get_post_categories($post_id);
if (empty($categories)) {
return false;
}
// Get number of posts to display (default: 3)
$posts_per_page = get_option('roi_related_posts_count', 3);
// Query arguments
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $posts_per_page,
'post__not_in' => array($post_id),
'category__in' => $categories,
'orderby' => 'rand',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
);
// Allow filtering of query args
$args = apply_filters('roi_related_posts_args', $args, $post_id);
// Get related posts
$related_query = new WP_Query($args);
return $related_query->have_posts() ? $related_query : false;
}
/**
* Display related posts section
*
* @param int|null $post_id Optional. Post ID. Default is current post.
* @return void
*/
function roi_display_related_posts($post_id = null) {
// Get post ID
if (!$post_id) {
$post_id = get_the_ID();
}
// Check if related posts are enabled
$enabled = get_option('roi_related_posts_enabled', true);
if (!$enabled) {
return;
}
// Get related posts
$related_query = roi_get_related_posts($post_id);
if (!$related_query) {
return;
}
// Get configuration options
$title = get_option('roi_related_posts_title', __('Related Posts', 'roi-theme'));
$columns = get_option('roi_related_posts_columns', 3);
$show_excerpt = get_option('roi_related_posts_show_excerpt', true);
$show_date = get_option('roi_related_posts_show_date', true);
$show_category = get_option('roi_related_posts_show_category', true);
$excerpt_length = get_option('roi_related_posts_excerpt_length', 20);
$background_colors = get_option('roi_related_posts_bg_colors', array(
'#1a73e8', // Blue
'#e91e63', // Pink
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#00bcd4', // Cyan
));
// Calculate Bootstrap column class
$col_class = roi_get_column_class($columns);
// Start output
?>
<section class="related-posts-section">
<div class="related-posts-container">
<?php if ($title) : ?>
<h2 class="related-posts-title"><?php echo esc_html($title); ?></h2>
<?php endif; ?>
<div class="row g-4">
<?php
$color_index = 0;
while ($related_query->have_posts()) :
$related_query->the_post();
$has_thumbnail = has_post_thumbnail();
// Get background color for posts without image
$bg_color = $background_colors[$color_index % count($background_colors)];
$color_index++;
?>
<div class="<?php echo esc_attr($col_class); ?>">
<article class="related-post-card <?php echo $has_thumbnail ? 'has-thumbnail' : 'no-thumbnail'; ?>">
<a href="<?php the_permalink(); ?>" class="related-post-link">
<?php if ($has_thumbnail) : ?>
<!-- Card with Image -->
<div class="related-post-thumbnail">
<?php
the_post_thumbnail('roi-thumbnail', array(
'alt' => the_title_attribute(array('echo' => false)),
'loading' => 'lazy',
));
?>
<?php if ($show_category) : ?>
<?php
$categories = get_the_category();
if (!empty($categories)) :
$category = $categories[0];
?>
<span class="related-post-category">
<?php echo esc_html($category->name); ?>
</span>
<?php endif; ?>
<?php endif; ?>
</div>
<?php else : ?>
<!-- Card without Image - Color Background -->
<div class="related-post-no-image" style="background-color: <?php echo esc_attr($bg_color); ?>;">
<div class="related-post-no-image-content">
<h3 class="related-post-no-image-title">
<?php the_title(); ?>
</h3>
<?php if ($show_category) : ?>
<?php
$categories = get_the_category();
if (!empty($categories)) :
$category = $categories[0];
?>
<span class="related-post-category no-image">
<?php echo esc_html($category->name); ?>
</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="related-post-content">
<?php if ($has_thumbnail) : ?>
<h3 class="related-post-title">
<?php the_title(); ?>
</h3>
<?php endif; ?>
<?php if ($show_excerpt && $excerpt_length > 0) : ?>
<div class="related-post-excerpt">
<?php echo wp_trim_words(get_the_excerpt(), $excerpt_length, '...'); ?>
</div>
<?php endif; ?>
<?php if ($show_date) : ?>
<div class="related-post-meta">
<time class="related-post-date" datetime="<?php echo esc_attr(get_the_date('c')); ?>">
<?php echo esc_html(get_the_date()); ?>
</time>
</div>
<?php endif; ?>
</div>
</a>
</article>
</div>
<?php endwhile; ?>
</div><!-- .row -->
</div><!-- .related-posts-container -->
</section><!-- .related-posts-section -->
<?php
// Reset post data
wp_reset_postdata();
}
/**
* Get Bootstrap column class based on number of columns
*
* @param int $columns Number of columns (1-4)
* @return string Bootstrap column classes
*/
function roi_get_column_class($columns) {
$columns = absint($columns);
switch ($columns) {
case 1:
return 'col-12';
case 2:
return 'col-12 col-md-6';
case 3:
return 'col-12 col-sm-6 col-lg-4';
case 4:
return 'col-12 col-sm-6 col-lg-3';
default:
return 'col-12 col-sm-6 col-lg-4'; // Default to 3 columns
}
}
/**
* Hook related posts display after post content
*/
function roi_hook_related_posts() {
if (is_single() && !is_attachment()) {
roi_display_related_posts();
}
}
add_action('roi_after_post_content', 'roi_hook_related_posts');
/**
* Enqueue related posts styles
*/
function roi_enqueue_related_posts_styles() {
if (is_single() && !is_attachment()) {
$enabled = get_option('roi_related_posts_enabled', true);
if ($enabled) {
wp_enqueue_style(
'roirelated-posts',
get_template_directory_uri() . '/Assets/Css/related-posts.css',
array('roibootstrap'),
ROI_VERSION,
'all'
);
}
}
}
add_action('wp_enqueue_scripts', 'roi_enqueue_related_posts_styles');
/**
* Register related posts settings
* These can be configured via theme options or customizer
*/
function roi_related_posts_default_options() {
// Set default options if they don't exist
$defaults = array(
'roi_related_posts_enabled' => true,
'roi_related_posts_title' => __('Related Posts', 'roi-theme'),
'roi_related_posts_count' => 3,
'roi_related_posts_columns' => 3,
'roi_related_posts_show_excerpt' => true,
'roi_related_posts_excerpt_length' => 20,
'roi_related_posts_show_date' => true,
'roi_related_posts_show_category' => true,
'roi_related_posts_bg_colors' => array(
'#1a73e8', // Blue
'#e91e63', // Pink
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#00bcd4', // Cyan
),
);
foreach ($defaults as $option => $value) {
if (get_option($option) === false) {
add_option($option, $value);
}
}
}
add_action('after_setup_theme', 'roi_related_posts_default_options');

View File

View File

@@ -9,13 +9,30 @@ use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
* Inyecta anuncios dentro del contenido del post
* via filtro the_content
*
* Soporta:
* - Modo aleatorio (random) con posiciones variables
* - Configuracion de 1-8 ads maximo
* - Espacio minimo entre anuncios
* Soporta dos modos:
* - Solo parrafos: Logica clasica solo con parrafos (usa config de behavior)
* - Avanzado: Multiples tipos de elementos (H2, H3, p, img, lists, blockquotes, tables)
*
* El modo se determina por incontent_mode:
* - "paragraphs_only": usa config de behavior (insercion solo en parrafos)
* - Otros: usa config de incontent_advanced
*/
final class ContentAdInjector
{
/**
* Prioridades de elementos para seleccion
* Mayor = mas importante
*/
private const ELEMENT_PRIORITIES = [
'h2' => 10,
'p' => 8,
'h3' => 7,
'image' => 6,
'list' => 5,
'blockquote' => 4,
'table' => 3,
];
public function __construct(
private array $settings,
private AdsensePlacementRenderer $renderer
@@ -25,18 +42,37 @@ final class ContentAdInjector
* Filtra the_content para insertar anuncios
*/
public function inject(string $content): string
{
// DEBUG TEMPORAL
$debug = '<!-- ROI_AD_DEBUG: inject() called, content length=' . strlen($content) . ' -->';
// PASO 0: Validar longitud minima (aplica a todos los modos)
$minLength = (int)($this->settings['forms']['min_content_length'] ?? 500);
if (strlen(strip_tags($content)) < $minLength) {
return $debug . '<!-- SKIP: too short -->' . $content;
}
// Determinar modo de operacion
$mode = $this->settings['incontent_advanced']['incontent_mode'] ?? 'paragraphs_only';
$debug .= '<!-- MODE=' . $mode . ' -->';
if ($mode === 'paragraphs_only') {
return $debug . $this->injectParagraphsOnly($content);
}
return $debug . $this->injectAdvanced($content);
}
/**
* Modo solo parrafos: logica clasica que inserta anuncios unicamente despues de parrafos
*/
private function injectParagraphsOnly(string $content): string
{
if (!($this->settings['behavior']['post_content_enabled'] ?? false)) {
return $content;
}
// Verificar longitud minima
$minLength = (int)($this->settings['forms']['min_content_length'] ?? 500);
if (strlen(strip_tags($content)) < $minLength) {
return $content;
}
// Obtener configuracion
// Obtener configuracion de behavior (modo solo parrafos)
$minAds = (int)($this->settings['behavior']['post_content_min_ads'] ?? 1);
$maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 3);
$afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3);
@@ -58,7 +94,7 @@ final class ContentAdInjector
}
// Calcular posiciones de insercion
$adPositions = $this->calculateAdPositions(
$adPositions = $this->calculateParagraphsOnlyPositions(
$totalParagraphs,
$afterParagraphs,
$minBetween,
@@ -72,9 +108,452 @@ final class ContentAdInjector
}
// Reconstruir contenido con anuncios insertados
return $this->buildContentWithAds($paragraphs, $adPositions);
return $this->buildParagraphsOnlyContent($paragraphs, $adPositions);
}
/**
* Modo avanzado: multiples tipos de elementos
*/
private function injectAdvanced(string $content): string
{
$config = $this->settings['incontent_advanced'] ?? [];
$debug = '<!-- ADV: config keys=' . implode(',', array_keys($config)) . ' -->';
// Obtener configuracion
$maxAds = (int)($config['incontent_max_total_ads'] ?? 8);
$minSpacing = (int)($config['incontent_min_spacing'] ?? 3);
$priorityMode = $config['incontent_priority_mode'] ?? 'position';
$format = $config['incontent_format'] ?? 'in-article';
$debug .= '<!-- ADV: maxAds=' . $maxAds . ' minSpacing=' . $minSpacing . ' -->';
// PASO 1: Escanear contenido para encontrar todas las ubicaciones
$locations = $this->scanContent($content);
$debug .= '<!-- ADV: scanContent found ' . count($locations) . ' locations -->';
if (empty($locations)) {
return $debug . '<!-- ADV: NO locations found -->' . $content;
}
// PASO 2: Filtrar por configuracion (enabled)
$locations = $this->filterByEnabled($locations, $config);
$debug .= '<!-- ADV: filterByEnabled left ' . count($locations) . ' -->';
if (empty($locations)) {
return $debug . '<!-- ADV: EMPTY after filterByEnabled -->' . $content;
}
// PASO 3: Aplicar probabilidad deterministica
$postId = get_the_ID() ?: 0;
$locations = $this->applyProbability($locations, $config, $postId);
$debug .= '<!-- ADV: applyProbability left ' . count($locations) . ' -->';
if (empty($locations)) {
return $debug . '<!-- ADV: EMPTY after applyProbability -->' . $content;
}
// PASO 4-5: Filtrar por espaciado y limite (segun priority_mode)
if ($priorityMode === 'priority') {
$locations = $this->filterByPriorityFirst($locations, $minSpacing, $maxAds);
} else {
$locations = $this->filterByPositionFirst($locations, $minSpacing, $maxAds);
}
$debug .= '<!-- ADV: filterBySpacing left ' . count($locations) . ' -->';
if (empty($locations)) {
return $debug . '<!-- ADV: EMPTY after filterBySpacing -->' . $content;
}
// Ordenar por posicion para insercion correcta
usort($locations, fn($a, $b) => $a['position'] <=> $b['position']);
$debug .= '<!-- ADV: INSERTING ' . count($locations) . ' ads -->';
// PASO 6: Insertar anuncios
return $debug . $this->insertAds($content, $locations, $format);
}
/**
* PASO 1: Escanea el contenido para encontrar ubicaciones elegibles
*
* IMPORTANTE: No inserta anuncios:
* - Dentro de tablas (<table>...</table>)
* - Dentro de embeds/iframes (YouTube, etc.)
* - Despues de tablas (tables excluidas completamente)
*
* @return array{position: int, type: string, tag: string, element_index: int}[]
*/
private function scanContent(string $content): array
{
$locations = [];
$elementIndex = 0;
// Primero, mapear zonas prohibidas (tablas, iframes, embeds)
$forbiddenZones = $this->mapForbiddenZones($content);
// Regex para encontrar tags de cierre de elementos de bloque
// NOTA: Excluimos </table> - no queremos insertar despues de tablas
$pattern = '/(<\/(?:p|h2|h3|figure|ul|ol|blockquote)>)/i';
// Encontrar todas las coincidencias con sus posiciones
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$tag = strtolower($match[0]);
$position = $match[1] + strlen($match[0]); // Posicion despues del tag
// Verificar que no este dentro de una zona prohibida
if ($this->isInForbiddenZone($position, $forbiddenZones)) {
continue;
}
$type = $this->getTypeFromTag($tag);
if ($type) {
$locations[] = [
'position' => $position,
'type' => $type,
'tag' => $tag,
'element_index' => $elementIndex++,
];
}
}
}
// Detectar imagenes standalone (no dentro de figure ni zonas prohibidas)
$locations = array_merge($locations, $this->scanStandaloneImages($content, $elementIndex, $forbiddenZones));
// Validar listas (minimo 3 items)
$locations = $this->validateLists($content, $locations);
// Ordenar por posicion
usort($locations, fn($a, $b) => $a['position'] <=> $b['position']);
// Reasignar indices de elemento
foreach ($locations as $i => &$loc) {
$loc['element_index'] = $i;
}
return $locations;
}
/**
* Mapea zonas donde NO se deben insertar anuncios
* Incluye: tablas, iframes, embeds de video
*
* @return array{start: int, end: int}[]
*/
private function mapForbiddenZones(string $content): array
{
$zones = [];
$contentLength = strlen($content);
// Tablas: buscar cada <table> y su </table> correspondiente
$this->findMatchingTags($content, 'table', $zones);
// Iframes (YouTube, Vimeo, etc)
$this->findMatchingTags($content, 'iframe', $zones);
// Figure con clase wp-block-embed (embeds de WordPress)
if (preg_match_all('/<figure[^>]*class="[^"]*wp-block-embed[^"]*"[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$startPos = $match[1];
$closePos = strpos($content, '</figure>', $startPos);
if ($closePos !== false) {
$zones[] = [
'start' => $startPos,
'end' => $closePos + strlen('</figure>'),
];
}
}
}
return $zones;
}
/**
* Encuentra tags de apertura y cierre correspondientes
*/
private function findMatchingTags(string $content, string $tagName, array &$zones): void
{
$openTag = '<' . $tagName;
$closeTag = '</' . $tagName . '>';
$offset = 0;
while (($startPos = stripos($content, $openTag, $offset)) !== false) {
// Buscar el cierre correspondiente
$closePos = stripos($content, $closeTag, $startPos);
if ($closePos !== false) {
$zones[] = [
'start' => $startPos,
'end' => $closePos + strlen($closeTag),
];
$offset = $closePos + strlen($closeTag);
} else {
break;
}
}
}
/**
* Verifica si una posicion esta dentro de una zona prohibida
*/
private function isInForbiddenZone(int $position, array $forbiddenZones): bool
{
foreach ($forbiddenZones as $zone) {
if ($position >= $zone['start'] && $position <= $zone['end']) {
return true;
}
}
return false;
}
/**
* Convierte tag de cierre a tipo de elemento
* NOTA: </table> excluido - no insertamos ads despues de tablas
*/
private function getTypeFromTag(string $tag): ?string
{
return match ($tag) {
'</p>' => 'p',
'</h2>' => 'h2',
'</h3>' => 'h3',
'</figure>' => 'image',
'</ul>', '</ol>' => 'list',
'</blockquote>' => 'blockquote',
default => null,
};
}
/**
* Detecta imagenes que no estan dentro de figure ni zonas prohibidas
*/
private function scanStandaloneImages(string $content, int $startIndex, array $forbiddenZones = []): array
{
$locations = [];
// Encontrar todas las imagenes con sus posiciones
if (!preg_match_all('/<img[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
return $locations;
}
foreach ($matches[0] as $match) {
$imgTag = $match[0];
$imgPosition = $match[1];
// Verificar que no este en zona prohibida
if ($this->isInForbiddenZone($imgPosition, $forbiddenZones)) {
continue;
}
// Verificar si hay un <figure> abierto antes de esta imagen
$contentBefore = substr($content, 0, $imgPosition);
$lastFigureOpen = strrpos($contentBefore, '<figure');
$lastFigureClose = strrpos($contentBefore, '</figure>');
// Si hay figure abierto sin cerrar, esta imagen esta dentro de figure
if ($lastFigureOpen !== false && ($lastFigureClose === false || $lastFigureClose < $lastFigureOpen)) {
continue; // Ignorar, se contara con </figure>
}
// Imagen standalone - calcular posicion despues del tag
$endPosition = $imgPosition + strlen($imgTag);
// Si la imagen esta seguida de </p> o similar, usar esa posicion
$contentAfter = substr($content, $endPosition, 20);
if (preg_match('/^\s*<\/p>/i', $contentAfter, $closeMatch)) {
// La imagen esta dentro de un parrafo, no es standalone
continue;
}
$locations[] = [
'position' => $endPosition,
'type' => 'image',
'tag' => $imgTag,
'element_index' => $startIndex++,
];
}
return $locations;
}
/**
* Valida que las listas tengan minimo 3 items
*/
private function validateLists(string $content, array $locations): array
{
return array_filter($locations, function ($loc) use ($content) {
if ($loc['type'] !== 'list') {
return true;
}
// Encontrar el contenido de la lista
$endPos = $loc['position'];
$tag = $loc['tag'];
$openTag = str_replace('/', '', $tag); // </ul> -> <ul>
// Buscar hacia atras el tag de apertura
$contentBefore = substr($content, 0, $endPos);
$lastOpen = strrpos($contentBefore, '<' . substr($openTag, 1)); // <ul o <ol
if ($lastOpen === false) {
return false;
}
$listContent = substr($content, $lastOpen, $endPos - $lastOpen);
// Contar items (usando substr_count como indica el spec)
$itemCount = substr_count(strtolower($listContent), '<li');
return $itemCount >= 3;
});
}
/**
* PASO 2: Filtra ubicaciones por campos enabled
*/
private function filterByEnabled(array $locations, array $config): array
{
$enabledTypes = [];
$typeMapping = [
'h2' => 'incontent_after_h2_enabled',
'h3' => 'incontent_after_h3_enabled',
'p' => 'incontent_after_paragraphs_enabled',
'image' => 'incontent_after_images_enabled',
'list' => 'incontent_after_lists_enabled',
'blockquote' => 'incontent_after_blockquotes_enabled',
'table' => 'incontent_after_tables_enabled',
];
foreach ($typeMapping as $type => $field) {
if ($config[$field] ?? false) {
$enabledTypes[] = $type;
}
}
return array_filter($locations, fn($loc) => in_array($loc['type'], $enabledTypes, true));
}
/**
* PASO 3: Aplica probabilidad deterministica usando seed del dia
*/
private function applyProbability(array $locations, array $config, int $postId): array
{
// Calcular seed deterministico
$seed = crc32($postId . date('Y-m-d'));
mt_srand($seed);
$probMapping = [
'h2' => 'incontent_after_h2_probability',
'h3' => 'incontent_after_h3_probability',
'p' => 'incontent_after_paragraphs_probability',
'image' => 'incontent_after_images_probability',
'list' => 'incontent_after_lists_probability',
'blockquote' => 'incontent_after_blockquotes_probability',
'table' => 'incontent_after_tables_probability',
];
return array_filter($locations, function ($loc) use ($config, $probMapping) {
$field = $probMapping[$loc['type']] ?? null;
if (!$field) {
return true;
}
$probability = (int)($config[$field] ?? 100);
$roll = mt_rand(1, 100);
return $roll <= $probability;
});
}
/**
* PASO 4-5 (modo position): Filtrar por espaciado primero, luego por prioridad
*/
private function filterByPositionFirst(array $locations, int $minSpacing, int $maxAds): array
{
// PASO 4: Ordenar por posicion y filtrar por espaciado
usort($locations, fn($a, $b) => $a['element_index'] <=> $b['element_index']);
$filtered = [];
$lastIndex = -999;
foreach ($locations as $loc) {
if ($loc['element_index'] - $lastIndex >= $minSpacing) {
$filtered[] = $loc;
$lastIndex = $loc['element_index'];
}
}
// PASO 5: Si excede max, seleccionar por prioridad
if (count($filtered) > $maxAds) {
usort($filtered, fn($a, $b) =>
(self::ELEMENT_PRIORITIES[$b['type']] ?? 0) <=> (self::ELEMENT_PRIORITIES[$a['type']] ?? 0)
);
$filtered = array_slice($filtered, 0, $maxAds);
}
return $filtered;
}
/**
* PASO 4-5 (modo priority): Ordenar por prioridad primero, luego aplicar espaciado
*/
private function filterByPriorityFirst(array $locations, int $minSpacing, int $maxAds): array
{
// PASO 4: Ordenar por prioridad
usort($locations, fn($a, $b) =>
(self::ELEMENT_PRIORITIES[$b['type']] ?? 0) <=> (self::ELEMENT_PRIORITIES[$a['type']] ?? 0)
);
// PASO 5: Filtrar por espaciado en orden de prioridad
$selected = [];
$usedIndices = [];
foreach ($locations as $loc) {
if (count($selected) >= $maxAds) {
break;
}
// Verificar espaciado con ubicaciones ya seleccionadas
$violatesSpacing = false;
foreach ($usedIndices as $usedIndex) {
if (abs($loc['element_index'] - $usedIndex) < $minSpacing) {
$violatesSpacing = true;
break;
}
}
if (!$violatesSpacing) {
$selected[] = $loc;
$usedIndices[] = $loc['element_index'];
}
}
return $selected;
}
/**
* PASO 6: Inserta los anuncios en las posiciones calculadas
*/
private function insertAds(string $content, array $locations, string $format): string
{
// Insertar de atras hacia adelante para no afectar posiciones
$locations = array_reverse($locations);
$adCount = count($locations);
$currentAd = $adCount;
foreach ($locations as $loc) {
$adHtml = $this->renderer->renderSlot($this->settings, 'post-content-adv-' . $currentAd);
$content = substr_replace($content, $adHtml, $loc['position'], 0);
$currentAd--;
}
return $content;
}
// =========================================================================
// METODOS LEGACY (sin cambios para backward compatibility)
// =========================================================================
/**
* Divide el contenido en parrafos preservando el HTML
*/
@@ -103,11 +582,11 @@ final class ContentAdInjector
}
/**
* Calcula las posiciones donde insertar anuncios
* Calcula las posiciones donde insertar anuncios (modo solo parrafos)
*
* @return int[] Indices de parrafos despues de los cuales insertar ads
*/
private function calculateAdPositions(
private function calculateParagraphsOnlyPositions(
int $totalParagraphs,
int $afterFirst,
int $minBetween,
@@ -117,7 +596,6 @@ final class ContentAdInjector
): array {
// Calcular posiciones disponibles respetando el espacio minimo
$availablePositions = [];
$lastPosition = $afterFirst; // Primera posicion fija
// La primera posicion siempre es despues del parrafo indicado
if ($afterFirst < $totalParagraphs) {
@@ -178,9 +656,9 @@ final class ContentAdInjector
}
/**
* Reconstruye el contenido insertando anuncios en las posiciones indicadas
* Reconstruye el contenido insertando anuncios en las posiciones indicadas (modo solo parrafos)
*/
private function buildContentWithAds(array $paragraphs, array $adPositions): string
private function buildParagraphsOnlyContent(array $paragraphs, array $adPositions): string
{
$newContent = '';
$adsInserted = 0;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* Renderer para slots de AdSense
@@ -36,6 +37,11 @@ final class AdsensePlacementRenderer
*/
public function renderSlot(array $settings, string $location): string
{
// 0. Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
return '';
}
// 1. Validar is_enabled
if (!($settings['visibility']['is_enabled'] ?? false)) {
return '';
@@ -105,15 +111,24 @@ final class AdsensePlacementRenderer
{
$locationKey = str_replace('-', '_', $location);
// Manejar ubicaciones de in-content (post_content_1, post_content_2, etc.)
// Manejar ubicaciones de in-content legacy (post_content_1, post_content_2, etc.)
if (preg_match('/^post_content_(\d+)$/', $locationKey, $matches)) {
// In-content ads heredan la configuracion de post_content
// In-content ads heredan la configuracion de post_content (modo solo parrafos)
return [
'enabled' => $settings['behavior']['post_content_enabled'] ?? false,
'format' => $settings['behavior']['post_content_format'] ?? 'in-article',
];
}
// Manejar ubicaciones de in-content avanzado (post_content_adv_1, post_content_adv_2, etc.)
if (preg_match('/^post_content_adv_(\d+)$/', $locationKey, $matches)) {
// In-content ads avanzados usan configuracion de incontent_advanced
return [
'enabled' => true, // Siempre enabled porque ya pasaron los filtros en ContentAdInjector
'format' => $settings['incontent_advanced']['incontent_format'] ?? 'in-article',
];
}
// Mapeo de ubicaciones a grupos y campos
$locationMap = [
'post_top' => ['group' => 'behavior', 'enabled' => 'post_top_enabled', 'format' => 'post_top_format'],

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ArchiveHeader\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* ArchiveHeaderRenderer - Renderiza cabecera dinamica para paginas de archivo
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Archive Header
*
* CARACTERISTICAS:
* - Deteccion automatica del tipo de archivo (categoria, tag, autor, fecha, busqueda)
* - Titulo y descripcion dinamicos
* - Contador de posts opcional
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\ArchiveHeader\Infrastructure\Ui
*/
final class ArchiveHeaderRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'archive-header';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$typography = $data['typography'] ?? [];
$behavior = $data['behavior'] ?? [];
$cssRules = [];
// Container
$marginTop = $spacing['margin_top'] ?? '2rem';
$marginBottom = $spacing['margin_bottom'] ?? '2rem';
$padding = $spacing['padding'] ?? '1.5rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header', [
'margin-top' => $marginTop,
'margin-bottom' => $marginBottom,
'padding' => $padding,
]);
// Sticky behavior
$isSticky = $behavior['is_sticky'] ?? false;
$isSticky = $isSticky === true || $isSticky === '1' || $isSticky === 1;
if ($isSticky) {
$stickyOffset = $behavior['sticky_offset'] ?? '0';
$cssRules[] = $this->cssGenerator->generate('.archive-header', [
'position' => 'sticky',
'top' => $stickyOffset,
'z-index' => '100',
'background' => '#ffffff',
]);
}
// Title
$titleColor = $colors['title_color'] ?? '#0E2337';
$titleSize = $typography['title_size'] ?? '2rem';
$titleWeight = $typography['title_weight'] ?? '700';
$titleMarginBottom = $spacing['title_margin_bottom'] ?? '0.5rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header__title', [
'color' => $titleColor,
'font-size' => $titleSize,
'font-weight' => $titleWeight,
'margin-bottom' => $titleMarginBottom,
'line-height' => '1.2',
]);
// Prefix
$prefixColor = $colors['prefix_color'] ?? '#6b7280';
$cssRules[] = $this->cssGenerator->generate('.archive-header__prefix', [
'color' => $prefixColor,
'font-weight' => '400',
]);
// Description
$descColor = $colors['description_color'] ?? '#6b7280';
$descSize = $typography['description_size'] ?? '1rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header__description', [
'color' => $descColor,
'font-size' => $descSize,
'margin-top' => '0.5rem',
'line-height' => '1.6',
]);
// Post count badge
$countBgColor = $colors['count_bg_color'] ?? '#FF8600';
$countTextColor = $colors['count_text_color'] ?? '#ffffff';
$countSize = $typography['count_size'] ?? '0.875rem';
$countPadding = $spacing['count_padding'] ?? '0.25rem 0.75rem';
$cssRules[] = $this->cssGenerator->generate('.archive-header__count', [
'background-color' => $countBgColor,
'color' => $countTextColor,
'font-size' => $countSize,
'padding' => $countPadding,
'border-radius' => '9999px',
'font-weight' => '500',
'display' => 'inline-block',
'margin-left' => '0.75rem',
]);
return implode("\n", $cssRules);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$typography = $data['typography'] ?? [];
$headingLevel = $typography['heading_level'] ?? 'h1';
$showPostCount = $content['show_post_count'] ?? true;
$showPostCount = $showPostCount === true || $showPostCount === '1' || $showPostCount === 1;
$showDescription = $content['show_description'] ?? true;
$showDescription = $showDescription === true || $showDescription === '1' || $showDescription === 1;
// Get context-specific title and description
$titleData = $this->getContextualTitle($content);
$title = $titleData['title'];
$prefix = $titleData['prefix'];
$description = $showDescription ? $titleData['description'] : '';
// Get post count
$postCount = $this->getPostCount();
$countSingular = $content['posts_count_singular'] ?? 'publicacion';
$countPlural = $content['posts_count_plural'] ?? 'publicaciones';
$countText = $postCount === 1 ? $countSingular : $countPlural;
$containerClass = 'archive-header';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
// Title with optional prefix
$html .= sprintf('<%s class="archive-header__title">', esc_attr($headingLevel));
if (!empty($prefix)) {
$html .= sprintf(
'<span class="archive-header__prefix">%s</span> ',
esc_html($prefix)
);
}
$html .= esc_html($title);
// Post count badge
if ($showPostCount && $postCount > 0) {
$html .= sprintf(
'<span class="archive-header__count">%d %s</span>',
$postCount,
esc_html($countText)
);
}
$html .= sprintf('</%s>', esc_attr($headingLevel));
// Description
if (!empty($description)) {
$html .= sprintf(
'<p class="archive-header__description">%s</p>',
esc_html($description)
);
}
$html .= '</div>';
return $html;
}
/**
* Get contextual title based on current page type
*
* @param array $content Content settings from schema
* @return array{title: string, prefix: string, description: string}
*/
private function getContextualTitle(array $content): array
{
$title = '';
$prefix = '';
$description = '';
if (is_category()) {
$prefix = $content['category_prefix'] ?? 'Categoria:';
$title = single_cat_title('', false) ?: '';
$description = category_description() ?: '';
} elseif (is_tag()) {
$prefix = $content['tag_prefix'] ?? 'Etiqueta:';
$title = single_tag_title('', false) ?: '';
$description = tag_description() ?: '';
} elseif (is_author()) {
$prefix = $content['author_prefix'] ?? 'Articulos de:';
$title = get_the_author() ?: '';
$description = get_the_author_meta('description') ?: '';
} elseif (is_date()) {
$prefix = $content['date_prefix'] ?? 'Archivo:';
$title = $this->getDateArchiveTitle();
$description = '';
} elseif (is_search()) {
$prefix = $content['search_prefix'] ?? 'Resultados para:';
$title = get_search_query() ?: '';
$description = '';
} elseif (is_home()) {
$prefix = '';
$title = $content['blog_title'] ?? 'Blog';
$description = '';
} elseif (is_archive()) {
$prefix = '';
$title = get_the_archive_title() ?: 'Archivo';
$description = get_the_archive_description() ?: '';
} else {
$prefix = '';
$title = $content['blog_title'] ?? 'Blog';
$description = '';
}
return [
'title' => $title,
'prefix' => $prefix,
'description' => strip_tags($description),
];
}
/**
* Get formatted title for date archives
*/
private function getDateArchiveTitle(): string
{
if (is_day()) {
return get_the_date();
} elseif (is_month()) {
return get_the_date('F Y');
} elseif (is_year()) {
return get_the_date('Y');
}
return get_the_archive_title() ?: '';
}
/**
* Get total post count for current query
*/
private function getPostCount(): int
{
global $wp_query;
return $wp_query->found_posts ?? 0;
}
}

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\ContactForm\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* ContactFormRenderer - Renderiza formulario de contacto con webhook
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class ContactFormRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'contact-form';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -34,7 +37,7 @@ final class ContactFormRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -67,7 +70,7 @@ final class ContactFormRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'contact-form';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -76,22 +79,6 @@ final class ContactFormRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;

View File

@@ -9,11 +9,10 @@ use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
* Inyecta CSS critico en wp_head
*
* Prioridades:
* - P:-2 Font preload (antes de variables)
* - P:-1 Variables CSS (antes de Bootstrap)
* - P:2 Responsive critico (despues de Bootstrap critico)
*
* NOTA: Font preload deshabilitado - usando system fonts para CERO flash
*
* @package ROITheme\Public\CriticalCSS\Infrastructure\Services
*/
final class CriticalCSSInjector
@@ -22,11 +21,23 @@ final class CriticalCSSInjector
private readonly CriticalCSSCacheInterface $cache
) {}
/**
* Fuentes criticas para preload (pesos usados en navbar above-the-fold)
*/
private const CRITICAL_FONTS = [
'/Assets/Fonts/poppins-v24-latin-regular.woff2', // 400 - body text
'/Assets/Fonts/poppins-v24-latin-600.woff2', // 600 - navbar brand
'/Assets/Fonts/poppins-v24-latin-700.woff2', // 700 - headings
];
/**
* Registra hooks de WordPress
*/
public function register(): void
{
// Font preload: P:-2 (antes de todo, incluso variables)
add_action('wp_head', [$this, 'preloadFonts'], -2);
// Variables CSS: P:-1 (antes de CriticalBootstrapService P:0)
add_action('wp_head', [$this, 'injectVariables'], -1);
@@ -38,6 +49,25 @@ final class CriticalCSSInjector
add_action('wp_enqueue_scripts', [$this, 'dequeueInlinedCSS'], 999);
}
/**
* Inyecta preload links para fuentes criticas
*
* Resuelve el problema de "font swap" donde el fallback (106% size-adjust)
* causa un salto visual cuando Poppins se carga.
* Con preload, las fuentes llegan antes del primer paint.
*/
public function preloadFonts(): void
{
echo "<!-- TIPO 4: Font preload para evitar CLS -->\n";
foreach (self::CRITICAL_FONTS as $font) {
printf(
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
esc_url(get_template_directory_uri() . $font)
);
}
}
/**
* Inyecta variables CSS criticas
*/

View File

@@ -6,6 +6,8 @@ namespace ROITheme\Public\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/**
* CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar
@@ -27,6 +29,12 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class CtaBoxSidebarRenderer implements RendererInterface
{
/**
* Nombre del componente para visibilidad
* Evita strings hardcodeados y facilita mantenimiento
*/
private const COMPONENT_NAME = 'cta-box-sidebar';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -39,7 +47,13 @@ final class CtaBoxSidebarRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
// Evaluar visibilidad por tipo de página (usa Helper, NO cambia constructor)
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
// Validar visibilidad por usuario logueado
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
return '';
}
@@ -52,7 +66,7 @@ final class CtaBoxSidebarRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'cta-box-sidebar';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -60,22 +74,6 @@ final class CtaBoxSidebarRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];

View File

@@ -6,6 +6,8 @@ namespace ROITheme\Public\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/**
* Class CtaLetsTalkRenderer
@@ -34,6 +36,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class CtaLetsTalkRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'cta-lets-talk';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -54,7 +58,12 @@ final class CtaLetsTalkRenderer implements RendererInterface
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
// Validar visibilidad por usuario logueado
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
return '';
}
@@ -77,7 +86,7 @@ final class CtaLetsTalkRenderer implements RendererInterface
*/
public function supports(string $componentType): bool
{
return $componentType === 'cta-lets-talk';
return $componentType === self::COMPONENT_NAME;
}
/**
@@ -91,25 +100,6 @@ final class CtaLetsTalkRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
default => true,
};
}
/**
* Calcular clases de visibilidad responsive
*

View File

@@ -6,6 +6,8 @@ namespace ROITheme\Public\CtaPost\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/**
* CtaPostRenderer - Renderiza CTA promocional debajo del contenido
@@ -22,6 +24,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class CtaPostRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'cta-post';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -34,7 +38,12 @@ final class CtaPostRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
// Validar visibilidad por usuario logueado
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
return '';
}
@@ -46,7 +55,7 @@ final class CtaPostRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'cta-post';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -55,22 +64,6 @@ final class CtaPostRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\FeaturedImage\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* FeaturedImageRenderer - Renderiza la imagen destacada del post
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class FeaturedImageRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'featured-image';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -39,7 +42,7 @@ final class FeaturedImageRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -63,7 +66,7 @@ final class FeaturedImageRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'featured-image';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -71,25 +74,24 @@ final class FeaturedImageRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function hasPostThumbnail(): bool
{
return is_singular() && has_post_thumbnail();
if (!is_singular() || !has_post_thumbnail()) {
return false;
}
// Verificar que el archivo físico exista, no solo el attachment ID
$thumbnailId = get_post_thumbnail_id();
if (!$thumbnailId) {
return false;
}
$filePath = get_attached_file($thumbnailId);
if (empty($filePath) || !file_exists($filePath)) {
return false;
}
return true;
}
/**

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Footer\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* FooterRenderer - Renderiza el footer del sitio
@@ -34,9 +35,14 @@ final class FooterRenderer implements RendererInterface
public function render(Component $component): string
{
// Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
if (!PageVisibilityHelper::shouldShow('footer')) {
return '';
}
$data = $component->getData();
// Validar visibilidad
// Validar visibilidad básica
$visibility = $data['visibility'] ?? [];
if (!($visibility['is_enabled'] ?? true)) {
return '';

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Hero\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* Class HeroRenderer
@@ -33,6 +34,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class HeroRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'hero';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -48,7 +51,7 @@ final class HeroRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -68,7 +71,7 @@ final class HeroRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'hero';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -76,24 +79,6 @@ final class HeroRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'home':
return is_front_page() || is_home();
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
/**
* Generar CSS usando CSSGeneratorService
*
@@ -148,6 +133,9 @@ final class HeroRenderer implements RendererInterface
'padding' => "{$paddingVertical} 0",
'margin-bottom' => $marginBottom,
'min-height' => $minHeight,
'display' => 'flex',
'align-items' => 'center',
'justify-content' => 'center',
]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__title', [

View File

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

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Navbar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use Walker_Nav_Menu;
/**
@@ -28,6 +29,8 @@ use Walker_Nav_Menu;
*/
final class NavbarRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'navbar';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -43,6 +46,10 @@ final class NavbarRenderer implements RendererInterface
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$html = $this->buildMenu($data);
// Si is_critical=true, CSS ya fue inyectado en <head> por CriticalCSSService
@@ -281,7 +288,7 @@ final class NavbarRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'navbar';
return $componentType === self::COMPONENT_NAME;
}
}

View File

@@ -0,0 +1,651 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\PostGrid\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* PostGridRenderer - Renderiza grid de posts del loop principal de WordPress
*
* RESPONSABILIDAD: Generar HTML y CSS del componente Post Grid
*
* DIFERENCIA CON RelatedPostRenderer:
* - PostGrid usa global $wp_query (loop principal)
* - RelatedPost crea su propio WP_Query
*
* CARACTERISTICAS:
* - Grid responsive de cards con imagen, excerpt y meta
* - Usa loop principal de WordPress (no crea queries propias)
* - Paginacion nativa de WordPress
* - Estilos 100% desde BD via CSSGenerator
*
* @package ROITheme\Public\PostGrid\Infrastructure\Ui
*/
final class PostGridRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'post-grid';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$visibilityClass = $this->getVisibilityClass($data);
if ($visibilityClass === null) {
return '';
}
global $wp_query;
// Si no hay posts, mostrar mensaje
if (!have_posts()) {
$noPostsMessage = $data['content']['no_posts_message'] ?? 'No se encontraron publicaciones';
return $this->renderNoPostsMessage($noPostsMessage, $visibilityClass, $data);
}
$css = $this->generateCSS($data);
$html = $this->buildHTML($data, $visibilityClass);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
public function supports(string $componentType): bool
{
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
{
$value = $data['visibility']['is_enabled'] ?? false;
return $value === true || $value === '1' || $value === 1;
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
if (!$showDesktop && !$showMobile) {
return null;
}
if (!$showDesktop && $showMobile) {
return 'd-lg-none';
}
if ($showDesktop && !$showMobile) {
return 'd-none d-lg-block';
}
return '';
}
private function renderNoPostsMessage(string $message, string $visibilityClass, array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
$textColor = $colors['excerpt_color'] ?? '#6b7280';
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
$padding = $spacing['card_padding'] ?? '1.25rem';
$css = $this->cssGenerator->generate('.post-grid-no-posts', [
'background-color' => $bgColor,
'color' => $textColor,
'border' => "1px solid {$borderColor}",
'border-radius' => '0.5rem',
'padding' => '2rem',
'text-align' => 'center',
]);
$containerClass = 'post-grid-no-posts';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf(
'<div class="%s"><p class="mb-0">%s</p></div>',
esc_attr($containerClass),
esc_html($message)
);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];
$spacing = $data['spacing'] ?? [];
$effects = $data['visual_effects'] ?? [];
$typography = $data['typography'] ?? [];
$layout = $data['layout'] ?? [];
$cssRules = [];
// Colores
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
$metaColor = $colors['meta_color'] ?? '#9ca3af';
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
$paginationColor = $colors['pagination_color'] ?? '#0E2337';
$paginationActiveBg = $colors['pagination_active_bg'] ?? '#FF8600';
$paginationActiveColor = $colors['pagination_active_color'] ?? '#ffffff';
// Spacing
$gapHorizontal = $spacing['gap_horizontal'] ?? '24px';
$gapVertical = $spacing['gap_vertical'] ?? '24px';
$cardPadding = $spacing['card_padding'] ?? '20px';
$sectionMarginTop = $spacing['section_margin_top'] ?? '0px';
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '32px';
// Visual effects
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
$imageBorderRadius = $effects['image_border_radius'] ?? '0.375rem';
// Typography
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
$metaSize = $typography['meta_size'] ?? '0.8rem';
// Container
$cssRules[] = $this->cssGenerator->generate('.post-grid', [
'margin-top' => $sectionMarginTop,
'margin-bottom' => $sectionMarginBottom,
]);
// Row: usar display flex con gap, quitar margins/paddings de Bootstrap
$cssRules[] = ".post-grid .row {
display: flex;
flex-wrap: wrap;
column-gap: {$gapHorizontal};
row-gap: {$gapVertical};
margin: 0;
padding: 0;
}";
// Columnas: quitar padding de Bootstrap y margin-bottom
$cssRules[] = ".post-grid .post-card-col {
padding: 0;
margin: 0;
}";
// Card base - sin margin extra
$cssRules[] = ".post-grid .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
border-radius: {$cardBorderRadius};
box-shadow: {$cardShadow};
transition: {$cardTransition};
height: 100%;
overflow: hidden;
margin: 0;
}";
// Card hover
$cssRules[] = ".post-grid .card:hover {
background: {$cardHoverBgColor};
border-color: {$cardHoverBorderColor};
box-shadow: {$cardHoverShadow};
transform: translateY(-2px);
}";
// Card body
$cssRules[] = $this->cssGenerator->generate('.post-grid .card-body', [
'padding' => $cardPadding,
]);
// Card image
$cssRules[] = ".post-grid .card-img-top {
border-radius: {$imageBorderRadius} {$imageBorderRadius} 0 0;
object-fit: cover;
width: 100%;
height: 200px;
}";
// Card title
$cssRules[] = ".post-grid .card-title {
color: {$cardTitleColor};
font-size: {$cardTitleSize};
font-weight: {$cardTitleWeight};
line-height: 1.4;
margin-bottom: 0.75rem;
}";
// Card title hover
$cssRules[] = ".post-grid a:hover .card-title {
color: {$cardHoverBorderColor};
}";
// Excerpt
$cssRules[] = ".post-grid .card-text {
color: {$excerptColor};
font-size: {$excerptSize};
line-height: 1.6;
}";
// Meta
$cssRules[] = ".post-grid .post-meta {
color: {$metaColor};
font-size: {$metaSize};
}";
// Categories
$cssRules[] = ".post-grid .post-category {
background: {$categoryBgColor};
color: {$categoryTextColor};
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
text-decoration: none;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}";
$cssRules[] = ".post-grid .post-category:hover {
background: {$categoryTextColor};
color: #ffffff;
}";
// Pagination
$cssRules[] = ".post-grid .pagination {
margin-top: 2rem;
}";
$cssRules[] = ".post-grid .page-link {
color: {$paginationColor};
border: 1px solid {$cardBorderColor};
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
}";
$cssRules[] = ".post-grid .page-link:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$paginationActiveBg};
color: {$paginationActiveBg};
}";
$cssRules[] = ".post-grid .page-item.active .page-link,
.post-grid .nav-links .current {
background-color: {$paginationActiveBg};
border-color: {$paginationActiveBg};
color: {$paginationActiveColor};
}";
// WordPress pagination classes
$cssRules[] = ".post-grid .nav-links {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 2rem;
}";
$cssRules[] = ".post-grid .nav-links a,
.post-grid .nav-links span {
color: {$paginationColor};
border: 1px solid {$cardBorderColor};
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
}";
$cssRules[] = ".post-grid .nav-links a:hover {
background-color: rgba(255, 133, 0, 0.1);
border-color: {$paginationActiveBg};
color: {$paginationActiveBg};
}";
// Layout responsive columns
$colsDesktop = $layout['columns_desktop'] ?? '3';
$colsTablet = $layout['columns_tablet'] ?? '2';
$colsMobile = $layout['columns_mobile'] ?? '1';
// Mobile (1 col = no gap needed)
$mobileWidth = $this->getColumnWidth($colsMobile, $gapHorizontal);
$cssRules[] = "@media (max-width: 575.98px) {
.post-grid .post-card-col {
flex: 0 0 {$mobileWidth};
max-width: {$mobileWidth};
}
}";
// Tablet
$tabletWidth = $this->getColumnWidth($colsTablet, $gapHorizontal);
$cssRules[] = "@media (min-width: 576px) and (max-width: 991.98px) {
.post-grid .post-card-col {
flex: 0 0 {$tabletWidth};
max-width: {$tabletWidth};
}
}";
// Desktop
$desktopWidth = $this->getColumnWidth($colsDesktop, $gapHorizontal);
$cssRules[] = "@media (min-width: 992px) {
.post-grid .post-card-col {
flex: 0 0 {$desktopWidth};
max-width: {$desktopWidth};
}
}";
return implode("\n", $cssRules);
}
/**
* Calcula el ancho de columna considerando el gap
*
* Con gap en flexbox, el ancho debe ser:
* (100% - (n-1)*gap) / n
*
* @param string $cols Número de columnas
* @param string $gap Valor del gap (ej: '1.5rem', '24px')
* @return string Valor CSS con calc() si hay gap
*/
private function getColumnWidth(string $cols, string $gap): string
{
$colCount = (int)$cols;
if ($colCount <= 0) {
$colCount = 1;
}
// Si es 1 columna, no hay gap entre columnas
if ($colCount === 1) {
return '100%';
}
// Número de gaps = columnas - 1
$gapCount = $colCount - 1;
// calc((100% - (n-1)*gap) / n)
return sprintf('calc((100%% - %d * %s) / %d)', $gapCount, $gap, $colCount);
}
private function buildHTML(array $data, string $visibilityClass): string
{
$content = $data['content'] ?? [];
$typography = $data['typography'] ?? [];
$media = $data['media'] ?? [];
$layout = $data['layout'] ?? [];
$showThumbnail = $this->toBool($content['show_thumbnail'] ?? true);
$showExcerpt = $this->toBool($content['show_excerpt'] ?? true);
$showMeta = $this->toBool($content['show_meta'] ?? true);
$showCategories = $this->toBool($content['show_categories'] ?? true);
$excerptLength = (int)($content['excerpt_length'] ?? 20);
$readMoreText = $content['read_more_text'] ?? 'Leer mas';
$headingLevel = $typography['heading_level'] ?? 'h3';
$fallbackImage = $media['fallback_image'] ?? '';
$fallbackImageAlt = $media['fallback_image_alt'] ?? 'Imagen por defecto';
$imagePosition = $layout['image_position'] ?? 'top';
$containerClass = 'post-grid';
if (!empty($visibilityClass)) {
$containerClass .= ' ' . $visibilityClass;
}
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= '<div class="row">';
while (have_posts()) {
the_post();
$html .= $this->buildCardHTML(
$showThumbnail,
$showExcerpt,
$showMeta,
$showCategories,
$excerptLength,
$readMoreText,
$headingLevel,
$fallbackImage,
$fallbackImageAlt,
$imagePosition
);
}
$html .= '</div>';
// Paginacion nativa de WordPress
$html .= '<div class="pagination-wrapper">';
$html .= $this->buildPaginationHTML();
$html .= '</div>';
$html .= '</div>';
wp_reset_postdata();
return $html;
}
private function toBool(mixed $value): bool
{
return $value === true || $value === '1' || $value === 1;
}
private function buildCardHTML(
bool $showThumbnail,
bool $showExcerpt,
bool $showMeta,
bool $showCategories,
int $excerptLength,
string $readMoreText,
string $headingLevel,
string $fallbackImage,
string $fallbackImageAlt,
string $imagePosition
): string {
$permalink = get_permalink();
$title = get_the_title();
$html = '<div class="post-card-col">';
$html .= sprintf(
'<a href="%s" class="text-decoration-none">',
esc_url($permalink)
);
$cardClass = 'card h-100';
if ($imagePosition === 'left') {
$cardClass .= ' flex-row';
}
$html .= sprintf('<div class="%s">', esc_attr($cardClass));
// Imagen
if ($showThumbnail && $imagePosition !== 'none') {
$html .= $this->buildImageHTML($fallbackImage, $fallbackImageAlt, $imagePosition);
}
$html .= '<div class="card-body">';
// Categorias
if ($showCategories) {
$html .= $this->buildCategoriesHTML();
}
// Titulo
$html .= sprintf(
'<%s class="card-title">%s</%s>',
esc_attr($headingLevel),
esc_html($title),
esc_attr($headingLevel)
);
// Meta
if ($showMeta) {
$html .= $this->buildMetaHTML();
}
// Excerpt
if ($showExcerpt) {
$html .= $this->buildExcerptHTML($excerptLength);
}
$html .= '</div>'; // card-body
$html .= '</div>'; // card
$html .= '</a>';
$html .= '</div>'; // col
return $html;
}
private function buildImageHTML(string $fallbackImage, string $fallbackImageAlt, string $imagePosition): string
{
if (has_post_thumbnail()) {
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
return get_the_post_thumbnail(
null,
'medium_large',
['class' => $imageClass, 'loading' => 'lazy']
);
}
if (!empty($fallbackImage)) {
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
return sprintf(
'<img src="%s" alt="%s" class="%s" loading="lazy">',
esc_url($fallbackImage),
esc_attr($fallbackImageAlt),
esc_attr($imageClass)
);
}
return '';
}
private function buildCategoriesHTML(): string
{
$categories = get_the_category();
if (empty($categories)) {
return '';
}
$html = '<div class="post-categories mb-2">';
foreach (array_slice($categories, 0, 2) as $category) {
$html .= sprintf(
'<span class="post-category">%s</span>',
esc_html($category->name)
);
}
$html .= '</div>';
return $html;
}
private function buildMetaHTML(): string
{
$date = get_the_date();
$author = get_the_author();
return sprintf(
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
esc_html($date),
esc_html($author)
);
}
private function buildExcerptHTML(int $length): string
{
$excerpt = get_the_excerpt();
if (empty($excerpt)) {
$excerpt = wp_trim_words(get_the_content(), $length, '...');
} else {
$excerpt = wp_trim_words($excerpt, $length, '...');
}
return sprintf(
'<p class="card-text">%s</p>',
esc_html($excerpt)
);
}
private function buildPaginationHTML(): string
{
global $wp_query;
$totalPages = $wp_query->max_num_pages;
if ($totalPages <= 1) {
return '';
}
$currentPage = max(1, get_query_var('paged', 1));
$html = '<nav aria-label="Paginacion"><ul class="pagination justify-content-center">';
// Boton Inicio (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Inicio</a></li>',
esc_url(get_pagenum_link(1))
);
// Numeros de pagina - mostrar 5 paginas
$visiblePages = 5;
$start = max(1, $currentPage - 2);
$end = min($totalPages, $start + $visiblePages - 1);
// Ajustar inicio si estamos cerca del final
if ($end - $start < $visiblePages - 1) {
$start = max(1, $end - $visiblePages + 1);
}
for ($i = $start; $i <= $end; $i++) {
if ($i === $currentPage) {
$html .= sprintf(
'<li class="page-item active"><span class="page-link">%d</span></li>',
$i
);
} else {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">%d</a></li>',
esc_url(get_pagenum_link($i)),
$i
);
}
}
// Ver mas (siguiente pagina)
if ($currentPage < $totalPages) {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Ver mas</a></li>',
esc_url(get_pagenum_link($currentPage + 1))
);
}
// Boton Fin (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Fin</a></li>',
esc_url(get_pagenum_link($totalPages))
);
$html .= '</ul></nav>';
return $html;
}
}

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\RelatedPost\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* RelatedPostRenderer - Renderiza seccion de posts relacionados
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class RelatedPostRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'related-post';
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
@@ -34,7 +37,7 @@ final class RelatedPostRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -51,7 +54,7 @@ final class RelatedPostRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'related-post';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -60,22 +63,6 @@ final class RelatedPostRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClass(array $data): ?string
{
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\SocialShare\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/**
* SocialShareRenderer - Renderiza botones de compartir en redes sociales
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class SocialShareRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'social-share';
private const NETWORKS = [
'facebook' => [
'field' => 'show_facebook',
@@ -84,7 +87,7 @@ final class SocialShareRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -96,7 +99,7 @@ final class SocialShareRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'social-share';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -105,22 +108,6 @@ final class SocialShareRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function generateCSS(array $data): string
{
$colors = $data['colors'] ?? [];

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\TableOfContents\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use DOMDocument;
use DOMXPath;
@@ -30,6 +31,8 @@ use DOMXPath;
*/
final class TableOfContentsRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'table-of-contents';
private array $headingCounter = [];
public function __construct(
@@ -44,7 +47,7 @@ final class TableOfContentsRenderer implements RendererInterface
return '';
}
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
@@ -63,7 +66,7 @@ final class TableOfContentsRenderer implements RendererInterface
public function supports(string $componentType): bool
{
return $componentType === 'table-of-contents';
return $componentType === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
@@ -71,22 +74,6 @@ final class TableOfContentsRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
switch ($showOn) {
case 'all':
return true;
case 'posts':
return is_single();
case 'pages':
return is_page();
default:
return true;
}
}
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{
if (!$desktop && !$mobile) {
@@ -124,8 +111,23 @@ final class TableOfContentsRenderer implements RendererInterface
return [];
}
// Intentar primero con contenido filtrado (respeta shortcodes, etc.)
$content = apply_filters('the_content', $post->post_content);
// Verificar si el contenido filtrado tiene headings
$hasFilteredHeadings = preg_match('/<h[2-6][^>]*>/i', $content);
// FIX: Si el contenido filtrado no tiene headings pero el raw si,
// usar el contenido raw. Esto ocurre cuando plugins como Thrive
// transforman el contenido para usuarios no logueados.
if (!$hasFilteredHeadings) {
$hasRawHeadings = preg_match('/<h[2-6][^>]*>/i', $post->post_content);
if ($hasRawHeadings) {
// Usar wpautop para dar formato basico al contenido raw
$content = wpautop($post->post_content);
}
}
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);

View File

@@ -6,6 +6,8 @@ namespace ROITheme\Public\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/**
* Class TopNotificationBarRenderer
@@ -34,6 +36,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class TopNotificationBarRenderer implements RendererInterface
{
private const COMPONENT_NAME = 'top-notification-bar';
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/
@@ -54,7 +58,12 @@ final class TopNotificationBarRenderer implements RendererInterface
}
// Validar visibilidad por página
if (!$this->shouldShowOnCurrentPage($data)) {
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
// Validar visibilidad por usuario logueado
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
return '';
}
@@ -78,7 +87,7 @@ final class TopNotificationBarRenderer implements RendererInterface
*/
public function supports(string $componentType): bool
{
return $componentType === 'top-notification-bar';
return $componentType === self::COMPONENT_NAME;
}
/**
@@ -92,46 +101,6 @@ final class TopNotificationBarRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true;
}
/**
* Verificar si debe mostrarse en la página actual
*
* @param array $data Datos del componente
* @return bool
*/
private function shouldShowOnCurrentPage(array $data): bool
{
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
return match ($showOn) {
'all' => true,
'home' => is_front_page(),
'posts' => is_single(),
'pages' => is_page(),
'custom' => $this->isInCustomPages($data),
default => true,
};
}
/**
* Verificar si está en páginas personalizadas
*
* @param array $data Datos del componente
* @return bool
*/
private function isInCustomPages(array $data): bool
{
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
if (empty($pageIds)) {
return false;
}
$allowedIds = array_map('trim', explode(',', $pageIds));
$currentId = (string) get_the_ID();
return in_array($currentId, $allowedIds, true);
}
/**
* Verificar si el componente fue dismissed por el usuario
*

View File

@@ -110,3 +110,14 @@
transform: rotate(360deg);
}
}
/* ========================================
FIX: Legacy wrapper with padding-top
Removes duplicate aspect-ratio from parent
containers that use the old padding-top trick
(prevents double spacing above videos)
======================================== */
div[style*="padding-top"]:has(> .youtube-facade) {
padding-top: 0 !important;
}

View File

View File

@@ -1,7 +1,7 @@
{
"component_name": "adsense-placement",
"version": "1.3.0",
"description": "Control de AdSense y Google Analytics - Con Anchor y Vignette Ads",
"version": "1.4.0",
"description": "Control de AdSense y Google Analytics - Con In-Content Ads Avanzado",
"groups": {
"visibility": {
"label": "Activacion",
@@ -113,6 +113,171 @@
}
}
},
"incontent_advanced": {
"label": "In-Content Ads Avanzado",
"priority": 69,
"fields": {
"incontent_mode": {
"type": "select",
"label": "Estrategia de insercion",
"default": "paragraphs_only",
"editable": true,
"options": {
"paragraphs_only": "Solo parrafos (clasico)",
"conservative": "Conservador - H2 y parrafos",
"balanced": "Balanceado - Multiples elementos",
"aggressive": "Intensivo - Todos los elementos",
"custom": "Personalizado"
},
"description": "Define donde se insertaran los anuncios dentro del contenido del post."
},
"incontent_after_h2_enabled": {
"type": "boolean",
"label": "Despues de H2",
"default": true,
"editable": true,
"description": "Insertar anuncios despues de encabezados H2"
},
"incontent_after_h2_probability": {
"type": "select",
"label": "Probabilidad H2",
"default": "100",
"editable": true,
"options": ["25", "50", "75", "100"],
"description": "Porcentaje de probabilidad de insercion"
},
"incontent_after_h3_enabled": {
"type": "boolean",
"label": "Despues de H3",
"default": true,
"editable": true,
"description": "Insertar anuncios despues de encabezados H3"
},
"incontent_after_h3_probability": {
"type": "select",
"label": "Probabilidad H3",
"default": "50",
"editable": true,
"options": ["25", "50", "75", "100"]
},
"incontent_after_paragraphs_enabled": {
"type": "boolean",
"label": "Despues de parrafos",
"default": true,
"editable": true,
"description": "Insertar anuncios despues de parrafos (ubicacion tradicional)"
},
"incontent_after_paragraphs_probability": {
"type": "select",
"label": "Probabilidad parrafos",
"default": "75",
"editable": true,
"options": ["25", "50", "75", "100"],
"description": "Porcentaje de probabilidad de insercion despues de parrafos"
},
"incontent_after_images_enabled": {
"type": "boolean",
"label": "Despues de imagenes",
"default": true,
"editable": true,
"description": "Insertar anuncios despues de figure o img standalone"
},
"incontent_after_images_probability": {
"type": "select",
"label": "Probabilidad imagenes",
"default": "75",
"editable": true,
"options": ["25", "50", "75", "100"]
},
"incontent_after_lists_enabled": {
"type": "boolean",
"label": "Despues de listas",
"default": false,
"editable": true,
"description": "Insertar anuncios despues de ul/ol (minimo 3 items)"
},
"incontent_after_lists_probability": {
"type": "select",
"label": "Probabilidad listas",
"default": "50",
"editable": true,
"options": ["25", "50", "75", "100"]
},
"incontent_after_blockquotes_enabled": {
"type": "boolean",
"label": "Despues de blockquotes",
"default": false,
"editable": true,
"description": "Insertar anuncios despues de citas en bloque"
},
"incontent_after_blockquotes_probability": {
"type": "select",
"label": "Probabilidad blockquotes",
"default": "50",
"editable": true,
"options": ["25", "50", "75", "100"]
},
"incontent_after_tables_enabled": {
"type": "boolean",
"label": "Despues de tablas",
"default": false,
"editable": true,
"description": "Insertar anuncios despues de tablas"
},
"incontent_after_tables_probability": {
"type": "select",
"label": "Probabilidad tablas",
"default": "50",
"editable": true,
"options": ["25", "50", "75", "100"]
},
"incontent_max_total_ads": {
"type": "select",
"label": "Maximo total de ads",
"default": "8",
"editable": true,
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25"],
"description": "Cantidad maxima de anuncios in-content por post"
},
"incontent_min_spacing": {
"type": "select",
"label": "Espaciado minimo",
"default": "3",
"editable": true,
"options": {
"1": "1 elemento",
"2": "2 elementos",
"3": "3 elementos",
"4": "4 elementos",
"5": "5 elementos",
"6": "6 elementos"
},
"description": "Minimo de elementos de bloque entre anuncios"
},
"incontent_format": {
"type": "select",
"label": "Formato de ads",
"default": "in-article",
"editable": true,
"options": {
"in-article": "In-Article (fluid)",
"auto": "Auto (responsive)"
},
"description": "Formato de anuncio para todas las ubicaciones in-content"
},
"incontent_priority_mode": {
"type": "select",
"label": "Estrategia de seleccion",
"default": "position",
"editable": true,
"options": {
"position": "Por posicion (distribucion uniforme)",
"priority": "Por prioridad (maximizar H2/H3)"
},
"description": "Como resolver conflictos cuando dos ubicaciones estan muy cerca"
}
}
},
"behavior": {
"label": "Ubicaciones en Posts",
"priority": 70,
@@ -437,6 +602,72 @@
}
}
},
"search_results": {
"label": "Resultados de Busqueda (ROI APU Search)",
"priority": 73,
"fields": {
"search_ads_enabled": {
"type": "boolean",
"label": "Activar ads en busqueda",
"default": false,
"editable": true,
"description": "Insertar anuncios en resultados del buscador APU"
},
"search_top_ad_enabled": {
"type": "boolean",
"label": "Anuncio fijo arriba",
"default": true,
"editable": true,
"description": "Mostrar anuncio debajo del campo de busqueda"
},
"search_top_ad_format": {
"type": "select",
"label": "Formato anuncio superior",
"default": "auto",
"editable": true,
"options": ["auto", "display", "in-article"]
},
"search_between_enabled": {
"type": "boolean",
"label": "Anuncios entre resultados",
"default": true,
"editable": true
},
"search_between_max": {
"type": "select",
"label": "Maximo anuncios entre resultados",
"default": "1",
"editable": true,
"options": ["1", "2", "3"],
"description": "Maximo 3 por politicas AdSense"
},
"search_between_format": {
"type": "select",
"label": "Formato entre resultados",
"default": "in-article",
"editable": true,
"options": ["auto", "in-article", "autorelaxed"]
},
"search_between_position": {
"type": "select",
"label": "Posicion de anuncios",
"default": "random",
"editable": true,
"options": {
"random": "Aleatorio",
"fixed": "Fijo (cada N resultados)",
"first_half": "Primera mitad"
}
},
"search_between_every": {
"type": "select",
"label": "Cada N resultados (si es fijo)",
"default": "5",
"editable": true,
"options": ["3", "4", "5", "6", "7", "8", "10"]
}
}
},
"layout": {
"label": "Ubicaciones Archivos/Globales",
"priority": 80,

233
Schemas/archive-header.json Normal file
View File

@@ -0,0 +1,233 @@
{
"component_name": "archive-header",
"version": "1.0.0",
"description": "Cabecera dinamica para paginas de archivo con titulo y descripcion contextual",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"label": "Activar componente",
"default": true,
"editable": true,
"required": true
},
"show_on_desktop": {
"type": "boolean",
"label": "Mostrar en escritorio",
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas >= 992px"
},
"show_on_mobile": {
"type": "boolean",
"label": "Mostrar en movil",
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
}
}
},
"content": {
"label": "Contenido",
"priority": 20,
"fields": {
"blog_title": {
"type": "text",
"label": "Titulo del blog",
"default": "Blog",
"editable": true,
"description": "Titulo mostrado en la pagina principal del blog"
},
"show_post_count": {
"type": "boolean",
"label": "Mostrar contador de posts",
"default": true,
"editable": true,
"description": "Muestra el numero de posts encontrados"
},
"show_description": {
"type": "boolean",
"label": "Mostrar descripcion",
"default": true,
"editable": true,
"description": "Muestra la descripcion de categoria/tag si existe"
},
"category_prefix": {
"type": "text",
"label": "Prefijo categoria",
"default": "Categoria:",
"editable": true
},
"tag_prefix": {
"type": "text",
"label": "Prefijo etiqueta",
"default": "Etiqueta:",
"editable": true
},
"author_prefix": {
"type": "text",
"label": "Prefijo autor",
"default": "Articulos de:",
"editable": true
},
"date_prefix": {
"type": "text",
"label": "Prefijo fecha",
"default": "Archivo:",
"editable": true
},
"search_prefix": {
"type": "text",
"label": "Prefijo busqueda",
"default": "Resultados para:",
"editable": true
},
"posts_count_singular": {
"type": "text",
"label": "Texto singular posts",
"default": "publicacion",
"editable": true
},
"posts_count_plural": {
"type": "text",
"label": "Texto plural posts",
"default": "publicaciones",
"editable": true
}
}
},
"typography": {
"label": "Tipografia",
"priority": 30,
"fields": {
"heading_level": {
"type": "select",
"label": "Nivel de encabezado",
"default": "h1",
"editable": true,
"options": ["h1", "h2", "h3", "h4", "h5", "h6"],
"description": "Nivel semantico del titulo para SEO"
},
"title_size": {
"type": "text",
"label": "Tamano titulo",
"default": "2rem",
"editable": true
},
"title_weight": {
"type": "text",
"label": "Peso titulo",
"default": "700",
"editable": true
},
"description_size": {
"type": "text",
"label": "Tamano descripcion",
"default": "1rem",
"editable": true
},
"count_size": {
"type": "text",
"label": "Tamano contador",
"default": "0.875rem",
"editable": true
}
}
},
"colors": {
"label": "Colores",
"priority": 40,
"fields": {
"title_color": {
"type": "color",
"label": "Color titulo",
"default": "#0E2337",
"editable": true
},
"description_color": {
"type": "color",
"label": "Color descripcion",
"default": "#6b7280",
"editable": true
},
"count_bg_color": {
"type": "color",
"label": "Fondo contador",
"default": "#FF8600",
"editable": true
},
"count_text_color": {
"type": "color",
"label": "Texto contador",
"default": "#ffffff",
"editable": true
},
"prefix_color": {
"type": "color",
"label": "Color prefijo",
"default": "#6b7280",
"editable": true
}
}
},
"spacing": {
"label": "Espaciado",
"priority": 50,
"fields": {
"margin_top": {
"type": "text",
"label": "Margen superior",
"default": "2rem",
"editable": true
},
"margin_bottom": {
"type": "text",
"label": "Margen inferior",
"default": "2rem",
"editable": true
},
"padding": {
"type": "text",
"label": "Padding interno",
"default": "1.5rem",
"editable": true
},
"title_margin_bottom": {
"type": "text",
"label": "Margen inferior titulo",
"default": "0.5rem",
"editable": true
},
"count_padding": {
"type": "text",
"label": "Padding contador",
"default": "0.25rem 0.75rem",
"editable": true
}
}
},
"behavior": {
"label": "Comportamiento",
"priority": 70,
"fields": {
"is_sticky": {
"type": "boolean",
"label": "Header fijo",
"default": false,
"editable": true,
"description": "Mantiene el header visible al hacer scroll"
},
"sticky_offset": {
"type": "text",
"label": "Offset sticky",
"default": "0",
"editable": true,
"description": "Distancia desde el top cuando es sticky"
}
}
}
}
}

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -28,13 +28,12 @@
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"hide_for_logged_in": {
"type": "boolean",
"label": "Ocultar para usuarios logueados",
"default": false,
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
"description": "No mostrar el CTA a usuarios con sesión iniciada en WordPress"
}
}
},

View File

@@ -29,26 +29,19 @@
"editable": true,
"description": "Muestra el botón en pantallas móviles (<992px). Por defecto oculto para ahorrar espacio en navbar móvil"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"required": true,
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas"
},
"description": "Define en qué páginas se mostrará el botón"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",
"default": true,
"editable": true,
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
},
"hide_for_logged_in": {
"type": "boolean",
"label": "Ocultar para usuarios logueados",
"default": false,
"editable": true,
"description": "No mostrar el botón a usuarios con sesión iniciada en WordPress"
}
}
},

View File

@@ -28,13 +28,12 @@
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"hide_for_logged_in": {
"type": "boolean",
"label": "Ocultar para usuarios logueados",
"default": false,
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
"description": "No mostrar el CTA a usuarios con sesión iniciada en WordPress"
}
}
},

View File

@@ -30,19 +30,6 @@
"editable": true,
"required": true,
"description": "Muestra la imagen en dispositivos moviles (<768px)"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"required": true,
"options": {
"all": "Todas las paginas",
"posts": "Solo posts individuales",
"pages": "Solo paginas"
},
"description": "Define en que tipo de contenido se muestra la imagen"
}
}
},

View File

@@ -31,20 +31,6 @@
"required": true,
"description": "Muestra el hero en dispositivos móviles (<768px)"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"required": true,
"options": {
"all": "Todas las páginas",
"posts": "Solo posts individuales",
"pages": "Solo páginas",
"home": "Solo página de inicio"
},
"description": "Define en qué tipo de contenido se mostrará el hero"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",

View File

@@ -29,19 +29,6 @@
"editable": true,
"description": "Muestra el menú en dispositivos de escritorio (≥768px)"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas"
},
"description": "Define en qué páginas se muestra el navbar"
},
"sticky_enabled": {
"type": "boolean",
"label": "Navbar fijo (sticky)",

271
Schemas/post-grid.json Normal file
View File

@@ -0,0 +1,271 @@
{
"component_name": "post-grid",
"version": "1.0.0",
"description": "Grid de posts para templates de listados usando el loop principal de WordPress",
"groups": {
"visibility": {
"label": "Visibilidad",
"priority": 10,
"fields": {
"is_enabled": {
"type": "boolean",
"default": true,
"label": "Habilitar componente"
},
"show_on_desktop": {
"type": "boolean",
"default": true,
"label": "Mostrar en desktop"
},
"show_on_mobile": {
"type": "boolean",
"default": true,
"label": "Mostrar en movil"
}
}
},
"content": {
"label": "Contenido",
"priority": 20,
"fields": {
"show_thumbnail": {
"type": "boolean",
"default": true,
"label": "Mostrar imagen destacada"
},
"show_excerpt": {
"type": "boolean",
"default": true,
"label": "Mostrar extracto"
},
"show_meta": {
"type": "boolean",
"default": true,
"label": "Mostrar metadatos (fecha, autor)"
},
"show_categories": {
"type": "boolean",
"default": true,
"label": "Mostrar categorias"
},
"excerpt_length": {
"type": "select",
"default": "20",
"label": "Longitud del extracto (palabras)",
"options": ["10", "15", "20", "25", "30"]
},
"read_more_text": {
"type": "text",
"default": "Leer mas",
"label": "Texto de leer mas"
},
"no_posts_message": {
"type": "text",
"default": "No se encontraron publicaciones",
"label": "Mensaje cuando no hay posts"
}
}
},
"typography": {
"label": "Tipografia",
"priority": 30,
"fields": {
"heading_level": {
"type": "select",
"default": "h3",
"label": "Nivel de encabezado de tarjetas",
"options": ["h2", "h3", "h4", "h5", "h6"]
},
"card_title_size": {
"type": "text",
"default": "1.1rem",
"label": "Tamano titulo de tarjeta"
},
"card_title_weight": {
"type": "text",
"default": "600",
"label": "Peso titulo de tarjeta"
},
"excerpt_size": {
"type": "text",
"default": "0.9rem",
"label": "Tamano de extracto"
},
"meta_size": {
"type": "text",
"default": "0.8rem",
"label": "Tamano de metadatos"
}
}
},
"colors": {
"label": "Colores",
"priority": 40,
"fields": {
"card_bg_color": {
"type": "color",
"default": "#ffffff",
"label": "Fondo de tarjeta"
},
"card_title_color": {
"type": "color",
"default": "#0E2337",
"label": "Color titulo de tarjeta"
},
"card_hover_bg_color": {
"type": "color",
"default": "#f9fafb",
"label": "Fondo hover de tarjeta"
},
"card_border_color": {
"type": "color",
"default": "#e5e7eb",
"label": "Color borde de tarjeta"
},
"card_hover_border_color": {
"type": "color",
"default": "#FF8600",
"label": "Color borde hover"
},
"excerpt_color": {
"type": "color",
"default": "#6b7280",
"label": "Color de extracto"
},
"meta_color": {
"type": "color",
"default": "#9ca3af",
"label": "Color de metadatos"
},
"category_bg_color": {
"type": "color",
"default": "#FFF5EB",
"label": "Fondo de categoria"
},
"category_text_color": {
"type": "color",
"default": "#FF8600",
"label": "Color texto categoria"
},
"pagination_color": {
"type": "color",
"default": "#0E2337",
"label": "Color de paginacion"
},
"pagination_active_bg": {
"type": "color",
"default": "#FF8600",
"label": "Fondo paginacion activa"
},
"pagination_active_color": {
"type": "color",
"default": "#ffffff",
"label": "Color texto paginacion activa"
}
}
},
"spacing": {
"label": "Espaciado",
"priority": 50,
"fields": {
"grid_gap": {
"type": "text",
"default": "1.5rem",
"label": "Espacio entre tarjetas"
},
"card_padding": {
"type": "text",
"default": "1.25rem",
"label": "Padding interno de tarjeta"
},
"section_margin_top": {
"type": "text",
"default": "0",
"label": "Margen superior de seccion"
},
"section_margin_bottom": {
"type": "text",
"default": "2rem",
"label": "Margen inferior de seccion"
}
}
},
"visual_effects": {
"label": "Efectos Visuales",
"priority": 60,
"fields": {
"card_border_radius": {
"type": "text",
"default": "0.5rem",
"label": "Radio de borde de tarjeta"
},
"card_shadow": {
"type": "text",
"default": "0 1px 3px rgba(0,0,0,0.1)",
"label": "Sombra de tarjeta"
},
"card_hover_shadow": {
"type": "text",
"default": "0 4px 12px rgba(0,0,0,0.15)",
"label": "Sombra hover de tarjeta"
},
"card_transition": {
"type": "text",
"default": "all 0.3s ease",
"label": "Transicion de tarjeta"
},
"image_border_radius": {
"type": "text",
"default": "0.375rem",
"label": "Radio de borde de imagen"
}
}
},
"layout": {
"label": "Disposicion",
"priority": 80,
"fields": {
"columns_desktop": {
"type": "select",
"default": "3",
"label": "Columnas en desktop",
"options": ["2", "3", "4"]
},
"columns_tablet": {
"type": "select",
"default": "2",
"label": "Columnas en tablet",
"options": ["1", "2", "3"]
},
"columns_mobile": {
"type": "select",
"default": "1",
"label": "Columnas en movil",
"options": ["1", "2"]
},
"image_position": {
"type": "select",
"default": "top",
"label": "Posicion de imagen",
"options": ["top", "left", "none"]
}
}
},
"media": {
"label": "Medios",
"priority": 90,
"fields": {
"fallback_image": {
"type": "url",
"default": "",
"label": "Imagen por defecto (URL)"
},
"fallback_image_alt": {
"type": "text",
"default": "Imagen por defecto",
"label": "Texto alternativo imagen por defecto"
}
}
}
}
}

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -27,14 +27,6 @@
"default": true,
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
}
}
},

View File

@@ -28,14 +28,6 @@
"editable": true,
"description": "Muestra el componente en pantallas < 992px"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "posts",
"editable": true,
"options": ["all", "posts", "pages"],
"description": "Tipos de contenido donde se muestra"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",

View File

@@ -15,20 +15,6 @@
"required": true,
"description": "Activa o desactiva la barra de notificación superior"
},
"show_on_pages": {
"type": "select",
"label": "Mostrar en",
"default": "all",
"editable": true,
"required": true,
"options": {
"all": "Todas las páginas",
"home": "Solo página de inicio",
"posts": "Solo posts individuales",
"pages": "Solo páginas"
},
"description": "Define en qué páginas se mostrará la barra"
},
"show_on_desktop": {
"type": "boolean",
"label": "Mostrar en desktop",
@@ -51,6 +37,13 @@
"default": true,
"editable": true,
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
},
"hide_for_logged_in": {
"type": "boolean",
"label": "Ocultar para usuarios logueados",
"default": false,
"editable": true,
"description": "No mostrar la barra a usuarios con sesión iniciada en WordPress"
}
}
},

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