66 Commits

Author SHA1 Message Date
FrankZamora
0d6b6db108 docs(api): add server info and test results to test-plan
- Add production server connection details
- Add deploy commands reference
- Document T01-T04, T13, T15-T16 test results (7 passed)
- Note pending tests requiring javascript_first_mode enabled

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 13:16:10 -06:00
FrankZamora
26546e1d69 feat(api): implement javascript-first architecture for cache compatibility
- Add REST endpoint GET /roi-theme/v1/adsense-placement/visibility
- Add Domain layer: UserContext, VisibilityDecision, AdsenseSettings VOs
- Add Application layer: CheckAdsenseVisibilityUseCase
- Add Infrastructure: AdsenseVisibilityChecker, Controller, Enqueuer
- Add JavaScript controller with localStorage caching
- Add test plan for production validation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 13:03:14 -06:00
FrankZamora
8936670451 feat(config): add adsense-javascript-first spec v1.5
- add new spec for javascript-first adsense architecture
- enables page cache compatibility by moving visibility decisions to js
- includes rest endpoint, localstorage caching, cls prevention
- full clean architecture compliance (9.6/10 score)
- rename base specs with 00- prefix for ordering

specs included:
- 00arquitectura-limpia/spec.md (renamed)
- 00estandares-codigo/spec.md (renamed)
- adsense-javascript-first/spec.md (new, v1.5)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 12:30:57 -06:00
FrankZamora
c9e9561984 fix(php): hide ad slots initially to prevent flash
Slots now start collapsed and only expand when filled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 18:13:39 -06:00
FrankZamora
a2dfd10f9e fix(js): implement google official css for unfilled adsense slots
Remove eager loading, revert to Intersection Observer with 600px
rootMargin. Use google css: ins[data-ad-status=unfilled]{display:none}

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 17:29:44 -06:00
FrankZamora
5971f2c971 feat(js): switch to eager loading to eliminate layout shift
Instead of lazy loading slots when they enter the viewport (which
causes layout shift when unfilled slots collapse), now all slots
are activated immediately on page load.

- Slots start collapsed (max-height:0, opacity:0) via CSS
- All slots are activated with 100ms stagger to avoid rate limiting
- Only slots confirmed as 'filled' expand and become visible
- Unfilled slots remain collapsed - zero layout shift

This completely eliminates the CLS issue where content would jump
when ad slots were hidden after entering the viewport.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 17:11:08 -06:00
FrankZamora
a4f63145dd fix(css): collapse ad slots initially to prevent layout shift
- Slots start with max-height:0, opacity:0, margin:0 (no visual space)
- Only expand when AdSense confirms filled status
- Use transition for smooth appearance
- Prevents layout shift when unfilled slots are hidden
- Combined with larger rootMargin (1000px) slots are pre-loaded
  before entering viewport, eliminating visible collapse

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 17:02:29 -06:00
FrankZamora
88103a774b perf(js): use fast polling for fill detection
Replace MutationObserver with 50ms polling strategy.
AdSense sets data-ad-status very quickly after iframe,
but MutationObserver sometimes has delays.

Max polling: 3s (60 attempts * 50ms)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 16:52:06 -06:00
FrankZamora
ffc22a21ea chore: bump version to 1.0.23 for cache busting 2025-12-10 16:44:55 -06:00
FrankZamora
ed45a9c821 fix(js): only use data-ad-status to determine slot fill state
AdSense injects iframe BEFORE setting data-ad-status, so using iframe
presence as fill indicator causes false positives. Slots with unfilled
ads were incorrectly marked as filled because iframe was detected first.

Now only data-ad-status is used for final state determination. If
data-ad-status is not yet set, the MutationObserver continues waiting.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 16:28:57 -06:00
FrankZamora
89a4fc5133 fix(php): use visibility+min-height instead of display:none for lazy slots
IntersectionObserver cannot detect elements with display:none because
they have 0x0 dimensions. Changed CSS approach to use visibility:hidden
with min-height:1px so IO can observe slots entering the viewport.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 16:11:42 -06:00
FrankZamora
449e2e1740 fix(php): use filter_var for proper boolean casting in config 2025-12-10 16:04:35 -06:00
FrankZamora
a2c4f857be fix(php): use wp_add_inline_script for defer compatibility
wp_localize_script doesn't work correctly with WordPress 6.3+
when using strategy => 'defer'. Switch to wp_add_inline_script
with 'before' position to ensure roiAdsenseConfig is defined
before the deferred script executes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:57:31 -06:00
FrankZamora
179a83e9cd feat(js): implement intersection observer lazy loading for adsense
- Add per-slot lazy loading with Intersection Observer API
- Implement fill detection via MutationObserver and data-ad-status
- Add configurable rootMargin and fillTimeout from database
- Generate dynamic CSS based on lazy_loading_enabled setting
- Add legacy mode fallback for browsers without IO support
- Include backup of previous implementation (adsense-loader.legacy.js)
- Add OpenSpec documentation with test plan (72 tests verified)

Schema changes:
- Add lazy_loading_enabled (boolean, default: true)
- Add lazy_rootmargin (select: 0-500px, default: 200)
- Add lazy_fill_timeout (select: 3000-10000ms, default: 5000)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:48:20 -06:00
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
215 changed files with 25259 additions and 5888 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,14 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"mcp__serena__activate_project",
"mcp__serena__find_symbol",
"Bash(ssh:*)",
"Bash(php:*)",
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_evaluate",
"mcp__playwright__browser_navigate"
]
}
}

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 # Claude Code tools
.playwright-mcp/ .playwright-mcp/
.serena/ .serena/
.claude/
nul nul

1
.husky/commit-msg Normal file
View File

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

18
AGENTS.md Normal file
View File

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

View File

View File

@@ -56,6 +56,11 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface
'adsense-placementRailFormat' => ['group' => 'behavior', 'attribute' => 'rail_format'], 'adsense-placementRailFormat' => ['group' => 'behavior', 'attribute' => 'rail_format'],
'adsense-placementRailTopOffset' => ['group' => 'behavior', 'attribute' => 'rail_top_offset'], 'adsense-placementRailTopOffset' => ['group' => 'behavior', 'attribute' => 'rail_top_offset'],
// BEHAVIOR (Lazy Loading)
'adsense-placementLazyLoadingEnabled' => ['group' => 'behavior', 'attribute' => 'lazy_loading_enabled'],
'adsense-placementLazyRootmargin' => ['group' => 'behavior', 'attribute' => 'lazy_rootmargin'],
'adsense-placementLazyFillTimeout' => ['group' => 'behavior', 'attribute' => 'lazy_fill_timeout'],
// LAYOUT (Archive/Global locations + formats) // LAYOUT (Archive/Global locations + formats)
'adsense-placementArchiveTopEnabled' => ['group' => 'layout', 'attribute' => 'archive_top_enabled'], 'adsense-placementArchiveTopEnabled' => ['group' => 'layout', 'attribute' => 'archive_top_enabled'],
'adsense-placementArchiveBetweenEnabled' => ['group' => 'layout', 'attribute' => 'archive_between_enabled'], 'adsense-placementArchiveBetweenEnabled' => ['group' => 'layout', 'attribute' => 'archive_between_enabled'],
@@ -95,6 +100,50 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface
'adsense-placementVignetteReshowEnabled' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_enabled'], 'adsense-placementVignetteReshowEnabled' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_enabled'],
'adsense-placementVignetteReshowTime' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_time'], 'adsense-placementVignetteReshowTime' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_time'],
'adsense-placementVignetteMaxPerSession' => ['group' => 'vignette_ads', 'attribute' => 'vignette_max_per_session'], '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; namespace ROITheme\Admin\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para AdSense Placement y Google Analytics * FormBuilder para AdSense Placement y Google Analytics
@@ -46,6 +47,7 @@ final class AdsensePlacementFormBuilder
$html .= $this->buildVisibilityGroup($componentId); $html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildDiagramSection(); $html .= $this->buildDiagramSection();
$html .= $this->buildPostLocationsGroup($componentId); $html .= $this->buildPostLocationsGroup($componentId);
$html .= $this->buildInContentAdvancedGroup($componentId);
$html .= $this->buildInContentAdsGroup($componentId); $html .= $this->buildInContentAdsGroup($componentId);
$html .= $this->buildExclusionsGroup($componentId); $html .= $this->buildExclusionsGroup($componentId);
$html .= ' </div>'; $html .= ' </div>';
@@ -57,6 +59,7 @@ final class AdsensePlacementFormBuilder
$html .= $this->buildRailAdsGroup($componentId); $html .= $this->buildRailAdsGroup($componentId);
$html .= $this->buildAnchorAdsGroup($componentId); $html .= $this->buildAnchorAdsGroup($componentId);
$html .= $this->buildVignetteAdsGroup($componentId); $html .= $this->buildVignetteAdsGroup($componentId);
$html .= $this->buildSearchResultsGroup($componentId);
$html .= ' </div>'; $html .= ' </div>';
$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 .= '<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>'; $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>';
$html .= '</div>'; $html .= '</div>';
@@ -299,6 +343,291 @@ final class AdsensePlacementFormBuilder
return $html; 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 * Seccion especial para in-content ads con configuracion de 1-8 random
*/ */
@@ -708,6 +1037,101 @@ final class AdsensePlacementFormBuilder
return $html; 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 private function buildExclusionsGroup(string $cid): string
{ {
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #6c757d;">'; $html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #6c757d;">';
@@ -758,6 +1182,55 @@ final class AdsensePlacementFormBuilder
$delayTimeout = $this->renderer->getFieldValue($cid, 'forms', 'delay_timeout', '5000'); $delayTimeout = $this->renderer->getFieldValue($cid, 'forms', 'delay_timeout', '5000');
$html .= $this->buildTextInput($cid . 'DelayTimeout', 'Timeout de delay (ms)', $delayTimeout); $html .= $this->buildTextInput($cid . 'DelayTimeout', 'Timeout de delay (ms)', $delayTimeout);
// Lazy Loading settings
$html .= '<hr class="my-3">';
$html .= '<p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #198754;"></i>';
$html .= ' Lazy Loading (Intersection Observer)';
$html .= ' <span class="badge bg-success ms-1">Nuevo</span>';
$html .= '</p>';
$html .= '<div class="alert alert-info small py-2 mb-2">';
$html .= ' <i class="bi bi-lightbulb me-1"></i>';
$html .= ' Carga anuncios individualmente al entrar al viewport. Mejora fill rate y reduce CLS.';
$html .= '</div>';
$lazyEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'lazy_loading_enabled', true);
$html .= $this->buildSwitch($cid . 'LazyLoadingEnabled', 'Activar Lazy Loading', $lazyEnabled, 'bi-eye');
$html .= '<div class="row g-2">';
$html .= ' <div class="col-md-6">';
$lazyRootmargin = $this->renderer->getFieldValue($cid, 'behavior', 'lazy_rootmargin', '200');
$html .= $this->buildSelect($cid . 'LazyRootmargin', 'Pre-carga (px)',
(string)$lazyRootmargin,
[
'0' => '0px (sin pre-carga)',
'100' => '100px',
'200' => '200px (recomendado)',
'300' => '300px',
'400' => '400px',
'500' => '500px'
]
);
$html .= ' </div>';
$html .= ' <div class="col-md-6">';
$lazyFillTimeout = $this->renderer->getFieldValue($cid, 'behavior', 'lazy_fill_timeout', '5000');
$html .= $this->buildSelect($cid . 'LazyFillTimeout', 'Timeout fill (ms)',
(string)$lazyFillTimeout,
[
'3000' => '3 segundos',
'5000' => '5 segundos (recomendado)',
'7000' => '7 segundos',
'10000' => '10 segundos'
]
);
$html .= ' </div>';
$html .= '</div>';
$html .= '<div class="alert alert-warning small py-2 mt-2 mb-0">';
$html .= ' <i class="bi bi-exclamation-triangle me-1"></i>';
$html .= ' <strong>Nota:</strong> Cambios requieren vaciar cache (Redis, W3TC) para aplicarse.';
$html .= '</div>';
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';
@@ -825,4 +1298,23 @@ final class AdsensePlacementFormBuilder
esc_attr($id), esc_html($label), esc_attr($id), $optionsHtml 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'], 'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'contactFormShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'contactFormVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'contactFormVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'contactFormVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'contactFormVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'contactFormExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'contactFormExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'contactFormExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'contactFormExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'], 'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui; namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Contact Form * FormBuilder para Contact Form
@@ -93,18 +94,46 @@ final class ContactFormFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // =============================================
$html .= ' <div class="mb-0 mt-3">'; // Checkboxes de visibilidad por tipo de página
$html .= ' <label for="contactFormShowOnPages" class="form-label small mb-1 fw-semibold">'; // Grupo especial: _page_visibility
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; // =============================================
$html .= ' Mostrar en'; $html .= ' <hr class="my-3">';
$html .= ' </label>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <select id="contactFormShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>'; $html .= ' </p>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $html .= ' </div>';
$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>';
$html .= '</div>'; $html .= '</div>';
@@ -598,4 +627,26 @@ final class ContactFormFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -30,7 +30,20 @@ final class CtaBoxSidebarFieldMapper implements FieldMapperInterface
'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], 'ctaHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'ctaVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'ctaExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'ctaExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'ctaExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'ctaExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'ctaTitle' => ['group' => 'content', 'attribute' => 'title'], 'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui; namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para el CTA Box Sidebar * FormBuilder para el CTA Box Sidebar
@@ -94,18 +95,61 @@ final class CtaBoxSidebarFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
// Obtener valores de _page_visibility (grupo especial)
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
// Grid 3 columnas según Design System
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'cta');
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0 mt-3">'; $html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaShowOnPages" class="form-label small mb-1 fw-semibold">'; $html .= ' <div class="form-check form-switch">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <input class="form-check-input" type="checkbox" id="ctaHideForLoggedIn" ';
$html .= ' Mostrar en'; $html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label 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 .= ' </label>';
$html .= ' <select id="ctaShowOnPages" class="form-select form-select-sm">'; $html .= ' </div>';
$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>'; $html .= ' </div>';
@@ -515,4 +559,29 @@ final class CtaBoxSidebarFormBuilder
return $html; return $html;
} }
/**
* Genera un checkbox de visibilidad por tipo de pagina
*
* Sigue Design System: form-check-checkbox es obligatorio
*/
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, bool $checked): string
{
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -26,7 +26,20 @@ final class CtaLetsTalkFieldMapper implements FieldMapperInterface
'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaLetsTalkShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], 'ctaLetsTalkHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'ctaLetsTalkVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaLetsTalkVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaLetsTalkVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaLetsTalkVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaLetsTalkVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'letsTalkExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'letsTalkExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'letsTalkExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'letsTalkExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'], 'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui; namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* Class CtaLetsTalkFormBuilder * Class CtaLetsTalkFormBuilder
@@ -120,16 +121,73 @@ final class CtaLetsTalkFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Select: Show on Pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'letsTalk');
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkIsCritical" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="ctaLetsTalkIsCritical" style="color: #495057;">';
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0">'; $html .= ' <div class="mb-0">';
$html .= ' <label for="ctaLetsTalkShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>'; $html .= ' <div class="form-check form-switch">';
$html .= ' <select id="ctaLetsTalkShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">'; $html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkHideForLoggedIn" ';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>'; $html .= ' <label class="form-check-label small" for="ctaLetsTalkHideForLoggedIn" style="color: #495057;">';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' </select>'; $html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
@@ -447,4 +505,26 @@ final class CtaLetsTalkFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -26,7 +26,20 @@ final class CtaPostFieldMapper implements FieldMapperInterface
'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'ctaPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'], 'ctaPostHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'ctaPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'ctaPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'ctaPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'ctaPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'ctaPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'ctaPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'ctaPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'ctaPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'ctaPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'], 'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui; namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para CTA Post * FormBuilder para CTA Post
@@ -85,17 +86,59 @@ final class CtaPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'ctaPost');
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0 mt-3">'; $html .= ' <div class="mb-0 mt-3">';
$html .= ' <label for="ctaPostShowOnPages" class="form-label small mb-1 fw-semibold">'; $html .= ' <div class="form-check form-switch">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <input class="form-check-input" type="checkbox" id="ctaPostHideForLoggedIn" ';
$html .= ' Mostrar en'; $html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label 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 .= ' </label>';
$html .= ' <select id="ctaPostShowOnPages" class="form-select form-select-sm">'; $html .= ' </div>';
$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>'; $html .= ' </div>';
@@ -437,4 +480,26 @@ final class CtaPostFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -8,9 +8,10 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
/** /**
* Value Object para ID único de snippet CSS * Value Object para ID único de snippet CSS
* *
* Soporta dos formatos: * Soporta tres formatos:
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3") * 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. * Esto permite migrar snippets existentes sin romper IDs.
*/ */
@@ -18,6 +19,7 @@ final class SnippetId
{ {
private const PREFIX = 'css_'; private const PREFIX = 'css_';
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/'; 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 const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
private function __construct( private function __construct(
@@ -47,7 +49,8 @@ final class SnippetId
// Validar formato generado (css_*) // Validar formato generado (css_*)
if (str_starts_with($id, self::PREFIX)) { 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( throw new ValidationException(
sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id) 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\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository; 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\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 * FormBuilder para gestión de CSS snippets en Admin Panel
@@ -19,6 +14,9 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
* - Constructor recibe AdminDashboardRenderer * - Constructor recibe AdminDashboardRenderer
* - Método buildForm() genera el HTML del formulario * - 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 * Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
*/ */
final class CustomCSSManagerFormBuilder final class CustomCSSManagerFormBuilder
@@ -28,120 +26,15 @@ final class CustomCSSManagerFormBuilder
private WordPressSnippetRepository $repository; private WordPressSnippetRepository $repository;
private GetAllSnippetsUseCase $getAllUseCase; private GetAllSnippetsUseCase $getAllUseCase;
private SaveSnippetUseCase $saveUseCase;
private DeleteSnippetUseCase $deleteUseCase;
public function __construct( public function __construct(
private readonly AdminDashboardRenderer $renderer private readonly AdminDashboardRenderer $renderer
) { ) {
// Crear repositorio y Use Cases internamente // Crear repositorio y Use Case para listar snippets
global $wpdb; global $wpdb;
$this->repository = new WordPressSnippetRepository($wpdb); $this->repository = new WordPressSnippetRepository($wpdb);
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository); $this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
$this->saveUseCase = new SaveSnippetUseCase($this->repository); // NOTA: El handler POST está en CustomCSSManagerBootstrap (admin_init)
$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);
} }
/** /**
@@ -160,13 +53,9 @@ final class CustomCSSManagerFormBuilder
// Header // Header
$html .= $this->buildHeader($componentId, count($snippets)); $html .= $this->buildHeader($componentId, count($snippets));
// Mensajes flash // Toast para mensajes (usa el sistema existente de admin-dashboard.js)
if ($message) { if ($message) {
$html .= sprintf( $html .= $this->buildToastTrigger($message);
'<div class="alert alert-%s m-3">%s</div>',
esc_attr($message['type']),
esc_html($message['text'])
);
} }
// Lista de snippets existentes // Lista de snippets existentes
@@ -367,7 +256,7 @@ final class CustomCSSManagerFormBuilder
// Botones // Botones
$html .= ' <div class="col-12">'; $html .= ' <div class="col-12">';
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">'; $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>';
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">'; $html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar'; $html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
@@ -449,14 +338,84 @@ final class CustomCSSManagerFormBuilder
$message = $_GET['roi_message'] ?? null; $message = $_GET['roi_message'] ?? null;
if ($message === 'success') { if ($message === 'success') {
return ['type' => 'success', 'text' => 'Snippet guardado correctamente']; return ['type' => 'success', 'text' => 'Cambios guardados correctamente'];
} }
if ($message === 'error') { if ($message === 'error') {
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido'); $error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
return ['type' => 'danger', 'text' => $error]; return ['type' => 'error', 'text' => $error];
} }
return null; 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'], 'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'featuredImageShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'featuredImageVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'featuredImageVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'featuredImageVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'featuredImageVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'featuredImageExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'featuredImageExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'featuredImageExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'featuredImageExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'], 'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Footer\Infrastructure\Ui; namespace ROITheme\Admin\Footer\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Footer * FormBuilder para Footer
@@ -90,6 +91,47 @@ final class FooterFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// =============================================
// Checkboxes de visibilidad por tipo de página
// Grupo especial: _page_visibility
// =============================================
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en tipos de pagina';
$html .= ' </p>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
$html .= ' </div>';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('footerVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
$html .= ' </div>';
$html .= ' </div>';
// =============================================
// Reglas de exclusion avanzadas
// Grupo especial: _exclusions (Plan 99.11)
// =============================================
$exclusionPartial = new ExclusionFormPartial($this->renderer);
$html .= $exclusionPartial->render($componentId, 'footer');
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';
@@ -410,4 +452,19 @@ final class FooterFormBuilder
} }
return (string) $value; return (string) $value;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
{
$checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
$html = '<div class="form-check">';
$html .= ' <input class="form-check-input" type="checkbox" id="' . esc_attr($id) . '" ' . $checked . '>';
$html .= ' <label class="form-check-label small" for="' . esc_attr($id) . '">';
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= '</div>';
return $html;
}
} }

View File

@@ -26,9 +26,21 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'], 'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Page Visibility (grupo especial _page_visibility)
'heroVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'heroVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'heroVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'heroVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'heroExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'heroExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'heroExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'heroExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'], 'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'], 'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Hero\Infrastructure\Ui; namespace ROITheme\Admin\Hero\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class HeroFormBuilder final class HeroFormBuilder
{ {
@@ -102,19 +103,46 @@ final class HeroFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
$html .= ' <div class="mb-2 mt-3">'; // Checkboxes de visibilidad por tipo de página
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">'; // Grupo especial: _page_visibility
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; // =============================================
$html .= ' Mostrar en'; $html .= ' <hr class="my-3">';
$html .= ' </label>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <select id="heroShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' </p>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$html .= ' </select>'; $showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $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 // Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true); $isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
@@ -427,4 +455,26 @@ final class HeroFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

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

View File

@@ -98,6 +98,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'Related Posts', 'label' => 'Related Posts',
'icon' => 'bi-grid-3x3-gap', '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' => [ 'contact-form' => [
'id' => 'contact-form', 'id' => 'contact-form',
'label' => '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'), 'label' => __('Contenido Principal', 'roi-theme'),
'icon' => 'bi-file-richtext', 'icon' => 'bi-file-richtext',
'description' => __('Secciones principales de páginas y posts', 'roi-theme'), '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' => [ 'ctas-conversion' => [
'label' => __('CTAs & Conversión', 'roi-theme'), 'label' => __('CTAs & Conversión', 'roi-theme'),

View File

@@ -60,6 +60,12 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
</div> </div>
</div> </div>
<?php
// Componentes con sistema de guardado propio (CRUD de entidades)
$componentsWithOwnSaveSystem = ['custom-css-manager'];
if (!in_array($activeComponent, $componentsWithOwnSaveSystem, true)):
?>
<!-- Botones Globales Save/Cancel --> <!-- 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;"> <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"> <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'); ?> <?php echo esc_html__('Guardar Cambios', 'roi-theme'); ?>
</button> </button>
</div> </div>
<?php endif; ?>
</div> </div>

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\Navbar\Infrastructure\Ui; namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class NavbarFormBuilder final class NavbarFormBuilder
{ {
@@ -105,17 +106,46 @@ final class NavbarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Select: Show on Pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-2">'; // Grupo especial: _page_visibility
$html .= ' <label for="navbarShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>'; // =============================================
$html .= ' <select id="navbarShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">'; $html .= ' <hr class="my-3">';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $html .= ' </p>';
$html .= ' </select>';
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $html .= ' </div>';
$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 // Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true); $sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
@@ -527,4 +557,26 @@ final class NavbarFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui; namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Related Posts * FormBuilder para Related Posts
@@ -86,18 +87,46 @@ final class RelatedPostFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // =============================================
$html .= ' <div class="mb-0 mt-3">'; // Checkboxes de visibilidad por tipo de página
$html .= ' <label for="relatedPostShowOnPages" class="form-label small mb-1 fw-semibold">'; // Grupo especial: _page_visibility
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; // =============================================
$html .= ' Mostrar en'; $html .= ' <hr class="my-3">';
$html .= ' </label>'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <select id="relatedPostShowOnPages" class="form-select form-select-sm">'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>'; $html .= ' </p>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
$html .= ' </select>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $html .= ' </div>';
$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>';
$html .= '</div>'; $html .= '</div>';
@@ -498,4 +527,26 @@ final class RelatedPostFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

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

View File

@@ -33,6 +33,8 @@ final class FieldMapperProvider
'Footer', 'Footer',
'ThemeSettings', 'ThemeSettings',
'AdsensePlacement', 'AdsensePlacement',
'ArchiveHeader',
'PostGrid',
]; ];
public function __construct( public function __construct(

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\Shared\Infrastructure\Services;
/**
* Servicio para procesar campos de exclusion antes de guardar en BD
*
* Convierte formatos de UI a JSON para almacenamiento.
*
* v1.1: Extraido de AdminAjaxHandler (SRP)
*
* @package ROITheme\Admin\Shared\Infrastructure\Services
*/
final class ExclusionFieldProcessor
{
/**
* Procesa un valor de campo de exclusion segun su tipo
*
* @param string $value Valor del campo (desde UI)
* @param string $type Tipo de campo: json_array, json_array_int, json_array_lines
* @return string JSON string para almacenar en BD
*/
public function process(string $value, string $type): string
{
return match ($type) {
'json_array' => $this->processJsonArray($value),
'json_array_int' => $this->processJsonArrayInt($value),
'json_array_lines' => $this->processJsonArrayLines($value),
default => $value,
};
}
/**
* "a, b, c" -> ["a", "b", "c"]
*/
private function processJsonArray(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_UNICODE);
}
/**
* "1, 2, 3" -> [1, 2, 3]
*/
private function processJsonArrayInt(string $value): string
{
$items = array_map('trim', explode(',', $value));
$items = array_filter($items, 'is_numeric');
$items = array_map('intval', $items);
return json_encode(array_values($items));
}
/**
* Lineas separadas -> array
*/
private function processJsonArrayLines(string $value): string
{
$items = preg_split('/\r\n|\r|\n/', $value);
$items = array_map('trim', $items);
$items = array_filter($items, fn($item) => $item !== '');
return json_encode(array_values($items), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}

View File

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

View File

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

View File

@@ -26,7 +26,19 @@ final class SocialShareFieldMapper implements FieldMapperInterface
'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'socialShareShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'socialShareVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'socialShareVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'socialShareVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'socialShareVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'socialShareVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'socialShareExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'socialShareExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'socialShareExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'socialShareExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'], 'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui; namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para Social Share * FormBuilder para Social Share
@@ -94,19 +95,46 @@ final class SocialShareFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
$html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-0 mt-3">'; // Grupo especial: _page_visibility
$html .= ' <label for="socialShareShowOnPages" class="form-label small mb-1 fw-semibold">'; // =============================================
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <hr class="my-3">';
$html .= ' Mostrar en'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' </label>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <select id="socialShareShowOnPages" class="form-select form-select-sm">'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>'; $html .= ' </p>';
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$html .= ' </select>'; $showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $html .= ' </div>';
$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>';
$html .= '</div>'; $html .= '</div>';
@@ -526,4 +554,26 @@ final class SocialShareFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -26,7 +26,19 @@ final class TableOfContentsFieldMapper implements FieldMapperInterface
'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'tocShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
// Page Visibility (grupo especial _page_visibility)
'tocVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'tocVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'tocVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'tocVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'tocVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'tocExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'tocExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'tocExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'tocExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'tocTitle' => ['group' => 'content', 'attribute' => 'title'], 'tocTitle' => ['group' => 'content', 'attribute' => 'title'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui; namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
/** /**
* FormBuilder para la Tabla de Contenido * FormBuilder para la Tabla de Contenido
@@ -94,19 +95,46 @@ final class TableOfContentsFormBuilder
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false); $showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
$html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile); $html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
// show_on_pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-0 mt-3">'; // Grupo especial: _page_visibility
$html .= ' <label for="tocShowOnPages" class="form-label small mb-1 fw-semibold">'; // =============================================
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <hr class="my-3">';
$html .= ' Mostrar en'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' </label>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <select id="tocShowOnPages" class="form-select form-select-sm">'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>'; $html .= ' </p>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts</option>';
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', false);
$html .= ' </select>'; $showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $html .= ' </div>';
$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>';
$html .= '</div>'; $html .= '</div>';
@@ -585,4 +613,26 @@ final class TableOfContentsFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -26,8 +26,21 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'], 'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'], 'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'], 'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'], 'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
'topBarHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
// Page Visibility (grupo especial _page_visibility)
'topBarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
'topBarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
'topBarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
'topBarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
// Exclusions (grupo especial _exclusions - Plan 99.11)
'topBarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
'topBarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
'topBarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
'topBarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
// Content // Content
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'], 'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui; namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer; use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
final class TopNotificationBarFormBuilder final class TopNotificationBarFormBuilder
{ {
@@ -105,24 +106,50 @@ final class TopNotificationBarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Select: Show on Pages // =============================================
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all'); // Checkboxes de visibilidad por tipo de página
$html .= ' <div class="mb-2 mt-3">'; // Grupo especial: _page_visibility
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">'; // =============================================
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>'; $html .= ' <hr class="my-3">';
$html .= ' Mostrar en'; $html .= ' <p class="small fw-semibold mb-2">';
$html .= ' </label>'; $html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
$html .= ' <select id="topBarShowOnPages" class="form-select form-select-sm">'; $html .= ' Mostrar en tipos de pagina';
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>'; $html .= ' </p>';
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>'; $showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>'; $showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
$html .= ' </select>'; $showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
$html .= ' <div class="row g-2">';
$html .= ' <div class="col-md-4">';
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityHome', 'Home', 'bi-house', $showOnHome);
$html .= ' </div>'; $html .= ' </div>';
$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 // Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true); $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 .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarIsCritical" '; $html .= ' <input class="form-check-input" type="checkbox" id="topBarIsCritical" ';
$html .= checked($isCritical, true, false) . '>'; $html .= checked($isCritical, true, false) . '>';
@@ -134,6 +161,20 @@ final class TopNotificationBarFormBuilder
$html .= ' </div>'; $html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
// Switch: Ocultar para usuarios logueados (Plan 99.16)
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarHideForLoggedIn" ';
$html .= checked($hideForLoggedIn, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarHideForLoggedIn" style="color: #495057;">';
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>'; $html .= ' </div>';
$html .= '</div>'; $html .= '</div>';
@@ -319,4 +360,26 @@ final class TopNotificationBarFormBuilder
return $html; return $html;
} }
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
{
$checked = $checked === true || $checked === '1' || $checked === 1;
$html = ' <div class="form-check form-check-checkbox mb-2">';
$html .= sprintf(
' <input class="form-check-input" type="checkbox" id="%s" %s>',
esc_attr($id),
$checked ? 'checked' : ''
);
$html .= sprintf(
' <label class="form-check-label small" for="%s">',
esc_attr($id)
);
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
$html .= sprintf(' %s', esc_html($label));
$html .= ' </label>';
$html .= ' </div>';
return $html;
}
} }

View File

@@ -5,7 +5,7 @@
* NO contiene CSS personalizado (ese va en critical-custom-temp.css - TIPO 3). * NO contiene CSS personalizado (ese va en critical-custom-temp.css - TIPO 3).
* *
* Componentes Bootstrap incluidos: * Componentes Bootstrap incluidos:
* - System Fonts (CERO flash - sin @font-face externos) * - Fonts (@font-face Poppins)
* - Variables CSS (:root) * - Variables CSS (:root)
* - Resets (box-sizing, body) * - Resets (box-sizing, body)
* - Container system * - Container system
@@ -30,29 +30,45 @@
*/ */
/* ========================================================================== /* ==========================================================================
SYSTEM FONTS - CERO Flash (sin fuentes externas) CRITICAL FONTS (Poppins - LCP optimization)
Usa fuentes nativas del sistema operativo: font-display: swap + preload = fuente carga rapido y siempre se muestra
- macOS/iOS: -apple-system, BlinkMacSystemFont size-adjust: 100.6% = fallback casi identico a Poppins (minimiza CLS)
- 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-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 { :root {
/* System Font Stack - CERO flash garantizado */ /* Fonts */
--font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, --font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
"Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", --bs-body-font-family: 'Poppins', 'Poppins Fallback', sans-serif;
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-primary: var(--font-system);
--bs-body-font-family: var(--font-system);
/* Theme Colors (críticos para above-the-fold) */ /* Theme Colors (críticos para above-the-fold) */
--color-navy-dark: #0E2337; --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-border-radius: var(--bs-border-radius, 0.375rem);
--bs-navbar-toggler-focus-width: 0.25rem; --bs-navbar-toggler-focus-width: 0.25rem;
--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out; --bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;
position: relative; /* position: controlado por CriticalCSSService según sticky_enabled */
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;

View File

@@ -2,12 +2,10 @@
* Sistema de Tipografías - ROI Theme * Sistema de Tipografías - ROI Theme
* *
* RESPONSABILIDAD: SOLO definición de fuentes y variables tipográficas * RESPONSABILIDAD: SOLO definición de fuentes y variables tipográficas
* - Declaraciones @font-face (comentadas - usar Google Fonts)
* - Variables CSS de tipografía (:root) * - Variables CSS de tipografía (:root)
* - Clases utilitarias de fuentes * - Clases utilitarias de fuentes
* *
* NOTA: Usando SYSTEM FONTS para CERO flash/parpadeo
* Las fuentes del sistema están disponibles instantáneamente.
*
* NO debe contener: * NO debe contener:
* - Estilos de body (van en style.css) * - Estilos de body (van en style.css)
* - Estilos de elementos HTML (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 { :root {
/* Stack de fuentes del sistema - disponibles instantáneamente */ /* Stack de fuentes del sistema - Fallback */
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, --font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
/* Fuente primaria - System fonts (CERO flash) */ /* Fuente primaria - Poppins con fallback ajustado (Fase 4.3 PageSpeed)
--font-primary: var(--font-system); 'Poppins Fallback' tiene size-adjust para reducir CLS durante font swap */
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para encabezados - System fonts */ /* Fuente para encabezados - Poppins con fallback ajustado */
--font-headings: var(--font-system); --font-headings: 'Poppins', 'Poppins Fallback', sans-serif;
/* Fuente para código (monospace) */ /* Fuente para código (monospace) */
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono',
@@ -48,22 +46,70 @@
*/ */
/* ============================================ /* ============================================
POPPINS - DESHABILITADO POPPINS (Self-hosted)
============================================ ============================================
Las @font-face de Poppins fueron eliminadas para Fuentes Poppins alojadas localmente para:
garantizar CERO flash/parpadeo en la carga de página. - 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) Pesos incluidos: 400, 500, 600, 700
que están disponibles instantáneamente en todos los Formato: WOFF2 (mejor compresión)
dispositivos sin necesidad de descarga.
Para reactivar Poppins en el futuro, descomentar las Fase 4.3 PageSpeed: Fallback con size-adjust para reducir CLS
declaraciones @font-face y actualizar las variables - size-adjust: 100.6% ajustado para coincidir mejor con Poppins
--font-primary y --font-headings. - 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 UTILIDADES DE FUENTES
============================================ */ ============================================ */

View File

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

View File

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

View File

@@ -1,216 +1,663 @@
/** /**
* Cargador Retrasado de AdSense * AdSense Lazy Loader con Intersection Observer
* *
* Este script retrasa la carga de Google AdSense hasta que haya interacción * Carga anuncios AdSense individualmente cuando entran al viewport,
* del usuario o se cumpla un timeout, mejorando el rendimiento de carga inicial. * detecta si reciben contenido, y oculta slots vacios.
* *
* @package ROI_Theme * @package ROI_Theme
* @since 1.0.0 * @since 1.5.0
* @version 2.0.0 - Refactorizado con Intersection Observer
*/ */
(function() { (function() {
'use strict'; 'use strict';
// Configuración // =========================================================================
const CONFIG = { // CONFIGURACION
timeout: 5000, // Timeout de fallback en milisegundos // =========================================================================
loadedClass: 'adsense-loaded',
debug: false // Cambiar a true para logs en consola /**
* Configuracion por defecto, sobrescrita por window.roiAdsenseConfig
*
* rootMargin: 600px precarga slots 600px antes de entrar al viewport.
* Esto da tiempo suficiente para que AdSense cargue el anuncio antes
* de que el usuario llegue al slot, evitando layout shift.
* Basado en best practices: https://support.google.com/adsense/answer/10762946
*/
var DEFAULT_CONFIG = {
lazyEnabled: true,
rootMargin: '600px 0px',
fillTimeout: 5000,
debug: false
}; };
// Estado /**
let adsenseLoaded = false; * Obtiene configuracion desde wp_localize_script o usa defaults
let loadTimeout = null; */
function getConfig() {
var wpConfig = window.roiAdsenseConfig || {};
return {
lazyEnabled: typeof wpConfig.lazyEnabled !== 'undefined' ? wpConfig.lazyEnabled : DEFAULT_CONFIG.lazyEnabled,
rootMargin: wpConfig.rootMargin || DEFAULT_CONFIG.rootMargin,
fillTimeout: typeof wpConfig.fillTimeout !== 'undefined' ? parseInt(wpConfig.fillTimeout, 10) : DEFAULT_CONFIG.fillTimeout,
debug: typeof wpConfig.debug !== 'undefined' ? wpConfig.debug : DEFAULT_CONFIG.debug
};
}
var CONFIG = getConfig();
// =========================================================================
// ESTADO GLOBAL
// =========================================================================
var libraryLoaded = false;
var libraryLoading = false;
var libraryLoadFailed = false;
var loadRetryCount = 0;
var MAX_LOAD_RETRIES = 1;
var RETRY_DELAY = 2000;
/** @type {IntersectionObserver|null} */
var slotObserver = null;
/** @type {Map<Element, MutationObserver>} */
var fillObservers = new Map();
/** @type {Map<Element, number>} */
var fillTimeouts = new Map();
/** @type {Set<Element>} */
var activatedSlots = new Set();
/** @type {Array<Function>} */
var pendingActivations = [];
// =========================================================================
// LOGGING
// =========================================================================
/** /**
* Registra mensajes de debug si el modo debug está habilitado * Log condicional basado en CONFIG.debug
* @param {string} message - El mensaje a registrar * @param {string} message
* @param {string} [level='log'] - 'log', 'warn', 'error'
*/ */
function debugLog(message) { function debugLog(message, level) {
if (CONFIG.debug && typeof console !== 'undefined') { if (!CONFIG.debug || typeof console === 'undefined') {
console.log('[AdSense Loader] ' + message); return;
}
level = level || 'log';
var prefix = '[AdSense Lazy] ';
if (level === 'error') {
console.error(prefix + message);
} else if (level === 'warn') {
console.warn(prefix + message);
} else {
console.log(prefix + message);
} }
} }
// =========================================================================
// DETECCION DE SOPORTE
// =========================================================================
/** /**
* Carga los scripts de AdSense e inicializa los ads * Verifica si el navegador soporta Intersection Observer
*/ */
function loadAdSense() { function hasIntersectionObserverSupport() {
// Prevenir múltiples cargas return typeof window.IntersectionObserver !== 'undefined';
if (adsenseLoaded) { }
debugLog('AdSense ya fue cargado, omitiendo...');
/**
* Verifica si el navegador soporta MutationObserver
*/
function hasMutationObserverSupport() {
return typeof window.MutationObserver !== 'undefined';
}
// =========================================================================
// CARGA DE BIBLIOTECA ADSENSE
// =========================================================================
/**
* Carga la biblioteca adsbygoogle.js
* @param {Function} onSuccess
* @param {Function} onError
*/
function loadAdSenseLibrary(onSuccess, onError) {
if (libraryLoaded) {
debugLog('Biblioteca ya cargada');
onSuccess();
return; return;
} }
adsenseLoaded = true; if (libraryLoading) {
debugLog('Cargando scripts de AdSense...'); debugLog('Biblioteca en proceso de carga, encolando callback');
pendingActivations.push(onSuccess);
// Limpiar el timeout si existe
if (loadTimeout) {
clearTimeout(loadTimeout);
loadTimeout = null;
}
// Remover event listeners para prevenir múltiples triggers
removeEventListeners();
// Cargar etiquetas de script de AdSense
loadAdSenseScripts();
// Ejecutar scripts de push de AdSense
executeAdSensePushScripts();
// Agregar clase loaded al body
document.body.classList.add(CONFIG.loadedClass);
debugLog('Carga de AdSense completada');
}
/**
* Encuentra y carga todas las etiquetas de script de AdSense retrasadas
*/
function loadAdSenseScripts() {
const delayedScripts = document.querySelectorAll('script[data-adsense-script]');
if (delayedScripts.length === 0) {
debugLog('No se encontraron scripts retrasados de AdSense');
return; return;
} }
debugLog('Se encontraron ' + delayedScripts.length + ' script(s) retrasado(s) de AdSense'); libraryLoading = true;
debugLog('Cargando biblioteca adsbygoogle.js...');
delayedScripts.forEach(function(oldScript) { var scriptTags = document.querySelectorAll('script[data-adsense-script]');
const newScript = document.createElement('script'); if (scriptTags.length === 0) {
debugLog('No se encontro script[data-adsense-script]', 'warn');
libraryLoading = false;
onError();
return;
}
// Copiar atributos var oldScript = scriptTags[0];
if (oldScript.src) { var newScript = document.createElement('script');
newScript.src = oldScript.src; newScript.src = oldScript.src;
}
// Establecer atributo async
newScript.async = true; newScript.async = true;
// Copiar crossorigin si está presente
if (oldScript.getAttribute('crossorigin')) { if (oldScript.getAttribute('crossorigin')) {
newScript.crossorigin = oldScript.getAttribute('crossorigin'); newScript.crossOrigin = oldScript.getAttribute('crossorigin');
} }
// Reemplazar script viejo con el nuevo newScript.onload = function() {
oldScript.parentNode.replaceChild(newScript, oldScript); debugLog('Biblioteca cargada exitosamente');
}); libraryLoaded = true;
} libraryLoading = false;
/**
* Ejecuta scripts de push de AdSense retrasados
*/
function executeAdSensePushScripts() {
const delayedPushScripts = document.querySelectorAll('script[data-adsense-push]');
if (delayedPushScripts.length === 0) {
debugLog('No se encontraron scripts de push retrasados de AdSense');
return;
}
debugLog('Se encontraron ' + delayedPushScripts.length + ' script(s) de push retrasado(s)');
// Inicializar array adsbygoogle si no existe
window.adsbygoogle = window.adsbygoogle || []; window.adsbygoogle = window.adsbygoogle || [];
delayedPushScripts.forEach(function(oldScript) { // Ejecutar callbacks pendientes
const scriptContent = oldScript.innerHTML; onSuccess();
while (pendingActivations.length > 0) {
var callback = pendingActivations.shift();
callback();
}
};
// Crear y ejecutar nuevo script newScript.onerror = function() {
const newScript = document.createElement('script'); debugLog('Error cargando biblioteca (intento ' + (loadRetryCount + 1) + ')', 'error');
newScript.innerHTML = scriptContent; libraryLoading = false;
newScript.type = 'text/javascript';
if (loadRetryCount < MAX_LOAD_RETRIES) {
loadRetryCount++;
debugLog('Reintentando en ' + RETRY_DELAY + 'ms...');
setTimeout(function() {
loadAdSenseLibrary(onSuccess, onError);
}, RETRY_DELAY);
} else {
debugLog('Maximo de reintentos alcanzado', 'error');
libraryLoadFailed = true;
onError();
}
};
// Reemplazar script viejo con el nuevo
oldScript.parentNode.replaceChild(newScript, oldScript); oldScript.parentNode.replaceChild(newScript, oldScript);
}
/**
* Marca todos los slots como error cuando la biblioteca falla
*/
function markAllSlotsAsError() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
slots.forEach(function(slot) {
slot.classList.add('roi-ad-error');
cleanupSlot(slot);
}); });
debugLog('Todos los slots marcados como error', 'error');
} }
/** // =========================================================================
* Manejador de eventos para interacciones del usuario // ACTIVACION DE SLOTS
*/ // =========================================================================
function handleUserInteraction() {
debugLog('Interacción del usuario detectada');
loadAdSense();
}
/** /**
* Remueve todos los event listeners * Activa un slot individual ejecutando adsbygoogle.push()
* @param {Element} slot
*/ */
function removeEventListeners() { function activateSlot(slot) {
window.removeEventListener('scroll', handleUserInteraction, { passive: true }); if (activatedSlots.has(slot)) {
window.removeEventListener('mousemove', handleUserInteraction, { passive: true }); debugLog('Slot ya activado, omitiendo');
window.removeEventListener('touchstart', handleUserInteraction, { passive: true });
window.removeEventListener('click', handleUserInteraction, { passive: true });
window.removeEventListener('keydown', handleUserInteraction, { passive: true });
}
/**
* Agrega event listeners para interacciones del usuario
*/
function addEventListeners() {
debugLog('Agregando event listeners para interacción del usuario');
// Evento de scroll - cargar en primer scroll
window.addEventListener('scroll', handleUserInteraction, { passive: true, once: true });
// Movimiento de mouse - cargar cuando el usuario mueve el mouse
window.addEventListener('mousemove', handleUserInteraction, { passive: true, once: true });
// Eventos táctiles - cargar en primer toque (móviles)
window.addEventListener('touchstart', handleUserInteraction, { passive: true, once: true });
// Eventos de click - cargar en primer click
window.addEventListener('click', handleUserInteraction, { passive: true, once: true });
// Eventos de teclado - cargar en primera pulsación de tecla
window.addEventListener('keydown', handleUserInteraction, { passive: true, once: true });
}
/**
* Establece timeout de fallback para cargar AdSense después del tiempo especificado
*/
function setTimeoutFallback() {
debugLog('Estableciendo timeout de fallback (' + CONFIG.timeout + 'ms)');
loadTimeout = setTimeout(function() {
debugLog('Timeout alcanzado, cargando AdSense');
loadAdSense();
}, CONFIG.timeout);
}
/**
* Inicializa el cargador retrasado de AdSense
*/
function init() {
// Verificar si el retardo de AdSense está habilitado
if (!window.roiAdsenseDelayed) {
debugLog('Retardo de AdSense no habilitado');
return; return;
} }
debugLog('Inicializando cargador retrasado de AdSense'); if (libraryLoadFailed) {
debugLog('Biblioteca fallida, marcando slot como error');
slot.classList.add('roi-ad-error');
return;
}
// Verificar si la página ya está interactiva o completa activatedSlots.add(slot);
if (document.readyState === 'interactive' || document.readyState === 'complete') {
debugLog('Página ya cargada, iniciando listeners'); var doActivation = function() {
addEventListeners(); var ins = slot.querySelector('ins.adsbygoogle');
setTimeoutFallback(); if (!ins) {
debugLog('No se encontro <ins> en slot', 'warn');
slot.classList.add('roi-ad-empty');
return;
}
debugLog('Activando slot: ' + (ins.getAttribute('data-ad-slot') || 'unknown'));
// Ejecutar push
try {
window.adsbygoogle = window.adsbygoogle || [];
window.adsbygoogle.push({});
} catch (e) {
debugLog('Error en push: ' + e.message, 'error');
slot.classList.add('roi-ad-error');
return;
}
// Iniciar observacion de llenado
startFillDetection(slot, ins);
};
// Si la biblioteca ya cargo, activar inmediatamente
if (libraryLoaded) {
doActivation();
} else { } else {
// Esperar a que el DOM esté listo // Cargar biblioteca y luego activar
debugLog('Esperando a DOMContentLoaded'); loadAdSenseLibrary(doActivation, function() {
document.addEventListener('DOMContentLoaded', function() { markAllSlotsAsError();
debugLog('DOMContentLoaded disparado');
addEventListeners();
setTimeoutFallback();
}); });
} }
} }
// Iniciar inicialización // =========================================================================
// DETECCION DE LLENADO
// =========================================================================
/** @type {Map<Element, number>} */
var pollIntervals = new Map();
/** Intervalo de polling rapido en ms */
var POLL_INTERVAL = 50;
/** Maximo de intentos de polling (50ms * 60 = 3 segundos max) */
var MAX_POLL_ATTEMPTS = 60;
/**
* Inicia la deteccion de llenado para un slot
* @param {Element} slot
* @param {Element} ins
*/
function startFillDetection(slot, ins) {
// Verificar inmediatamente si ya tiene contenido
if (checkFillStatus(slot, ins)) {
return;
}
// Estrategia: Polling rapido (50ms) para detectar data-ad-status lo antes posible.
// AdSense establece data-ad-status muy rapido despues de inyectar el iframe,
// pero MutationObserver a veces no lo detecta inmediatamente.
var pollCount = 0;
var pollId = setInterval(function() {
pollCount++;
if (checkFillStatus(slot, ins)) {
// Estado detectado, limpiar polling
clearInterval(pollId);
pollIntervals.delete(slot);
return;
}
// Si alcanzamos el maximo de intentos, marcar como vacio
if (pollCount >= MAX_POLL_ATTEMPTS) {
debugLog('Polling timeout alcanzado (' + (pollCount * POLL_INTERVAL) + 'ms)');
clearInterval(pollId);
pollIntervals.delete(slot);
markSlotEmpty(slot);
}
}, POLL_INTERVAL);
pollIntervals.set(slot, pollId);
}
/**
* Verifica el estado de llenado de un slot
* @param {Element} slot
* @param {Element} ins
* @returns {boolean} true si el estado fue determinado (filled o empty)
*/
function checkFillStatus(slot, ins) {
// IMPORTANTE: Solo data-ad-status es confiable para determinar el estado final.
// AdSense inyecta iframe ANTES de establecer data-ad-status, por lo que
// la presencia de iframe NO indica que el anuncio fue llenado.
var status = ins.getAttribute('data-ad-status');
// Estado definitivo: filled
if (status === 'filled') {
debugLog('Slot llenado (data-ad-status=filled)');
markSlotFilled(slot);
return true;
}
// Estado definitivo: unfilled (sin anuncio disponible)
if (status === 'unfilled') {
debugLog('Slot vacio (data-ad-status=unfilled)');
markSlotEmpty(slot);
return true;
}
// Si no hay data-ad-status, AdSense aun no ha respondido.
// NO usar iframe como criterio porque AdSense inyecta iframe incluso para unfilled.
// El MutationObserver seguira observando hasta que data-ad-status aparezca o timeout.
debugLog('Esperando data-ad-status...');
return false;
}
/**
* Marca un slot como llenado
* @param {Element} slot
*/
function markSlotFilled(slot) {
slot.classList.remove('roi-ad-empty', 'roi-ad-error');
slot.classList.add('roi-ad-filled');
cleanupSlot(slot);
}
/**
* Marca un slot como vacio
* @param {Element} slot
*/
function markSlotEmpty(slot) {
slot.classList.remove('roi-ad-filled', 'roi-ad-error');
slot.classList.add('roi-ad-empty');
cleanupSlot(slot);
}
/**
* Limpia observadores, timeouts e intervalos de un slot
* @param {Element} slot
*/
function cleanupSlot(slot) {
// Limpiar polling interval
if (pollIntervals.has(slot)) {
clearInterval(pollIntervals.get(slot));
pollIntervals.delete(slot);
}
// Limpiar timeout (legacy)
if (fillTimeouts.has(slot)) {
clearTimeout(fillTimeouts.get(slot));
fillTimeouts.delete(slot);
}
// Limpiar MutationObserver (legacy)
if (fillObservers.has(slot)) {
fillObservers.get(slot).disconnect();
fillObservers.delete(slot);
}
// Dejar de observar con IntersectionObserver
if (slotObserver) {
slotObserver.unobserve(slot);
}
}
// =========================================================================
// INTERSECTION OBSERVER
// =========================================================================
/**
* Inicializa el Intersection Observer para slots
*/
function initIntersectionObserver() {
if (!hasIntersectionObserverSupport()) {
debugLog('Sin soporte Intersection Observer, usando modo legacy', 'warn');
return false;
}
var options = {
root: null,
rootMargin: CONFIG.rootMargin,
threshold: 0
};
slotObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var slot = entry.target;
debugLog('Slot entro al viewport');
activateSlot(slot);
}
});
}, options);
debugLog('Intersection Observer inicializado con rootMargin: ' + CONFIG.rootMargin);
return true;
}
/**
* Observa todos los slots lazy en la pagina
*/
function observeAllSlots() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
debugLog('Encontrados ' + slots.length + ' slots para observar');
slots.forEach(function(slot) {
if (!activatedSlots.has(slot)) {
slotObserver.observe(slot);
}
});
}
/**
* Observa nuevos slots agregados dinamicamente
*/
function observeNewSlots() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
var newCount = 0;
slots.forEach(function(slot) {
if (!activatedSlots.has(slot)) {
slotObserver.observe(slot);
newCount++;
}
});
if (newCount > 0) {
debugLog('Agregados ' + newCount + ' nuevos slots al observer');
}
}
// =========================================================================
// MODO LEGACY (FALLBACK)
// =========================================================================
/**
* Variables para modo legacy
*/
var legacyLoaded = false;
var legacyTimeout = null;
/**
* Carga todos los ads en modo legacy (sin Intersection Observer)
*/
function loadAllAdsLegacy() {
if (legacyLoaded) {
return;
}
legacyLoaded = true;
debugLog('Modo legacy: Cargando todos los ads');
if (legacyTimeout) {
clearTimeout(legacyTimeout);
}
removeLegacyEventListeners();
loadAdSenseLibrary(function() {
executeAllPushScripts();
}, function() {
debugLog('Error en modo legacy', 'error');
});
}
/**
* Ejecuta todos los scripts de push en modo legacy
*/
function executeAllPushScripts() {
var pushScripts = document.querySelectorAll('script[data-adsense-push]');
debugLog('Ejecutando ' + pushScripts.length + ' scripts de push');
window.adsbygoogle = window.adsbygoogle || [];
pushScripts.forEach(function(oldScript) {
var newScript = document.createElement('script');
newScript.innerHTML = oldScript.innerHTML;
newScript.type = 'text/javascript';
oldScript.parentNode.replaceChild(newScript, oldScript);
});
document.body.classList.add('adsense-loaded');
}
/**
* Event handler para modo legacy
*/
function handleLegacyInteraction() {
debugLog('Interaccion detectada (modo legacy)');
loadAllAdsLegacy();
}
/**
* Agrega listeners para modo legacy
*/
function addLegacyEventListeners() {
window.addEventListener('scroll', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('mousemove', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('touchstart', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('click', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('keydown', handleLegacyInteraction, { passive: true, once: true });
}
/**
* Remueve listeners de modo legacy
*/
function removeLegacyEventListeners() {
window.removeEventListener('scroll', handleLegacyInteraction, { passive: true });
window.removeEventListener('mousemove', handleLegacyInteraction, { passive: true });
window.removeEventListener('touchstart', handleLegacyInteraction, { passive: true });
window.removeEventListener('click', handleLegacyInteraction, { passive: true });
window.removeEventListener('keydown', handleLegacyInteraction, { passive: true });
}
/**
* Inicia modo legacy con listeners de interaccion
*/
function initLegacyMode() {
debugLog('Iniciando modo legacy');
addLegacyEventListeners();
legacyTimeout = setTimeout(function() {
debugLog('Timeout legacy alcanzado');
loadAllAdsLegacy();
}, CONFIG.fillTimeout);
}
// =========================================================================
// EVENTO DINAMICO
// =========================================================================
/**
* Configura listener para ads dinamicos
*/
function setupDynamicAdsListener() {
window.addEventListener('roi-adsense-activate', function() {
debugLog('Evento roi-adsense-activate recibido');
if (CONFIG.lazyEnabled && slotObserver) {
observeNewSlots();
} else if (!legacyLoaded) {
loadAllAdsLegacy();
} else {
// Ya cargado en legacy, ejecutar nuevos push
activateDynamicSlotsLegacy();
}
});
}
/**
* Activa slots dinamicos en modo legacy
*/
function activateDynamicSlotsLegacy() {
var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]');
if (pendingPushScripts.length === 0) {
return;
}
debugLog('Activando ' + pendingPushScripts.length + ' slots dinamicos (legacy)');
window.adsbygoogle = window.adsbygoogle || [];
pendingPushScripts.forEach(function(oldScript) {
try {
var newScript = document.createElement('script');
newScript.type = 'text/javascript';
newScript.innerHTML = oldScript.innerHTML;
oldScript.parentNode.replaceChild(newScript, oldScript);
} catch (e) {
debugLog('Error activando slot dinamico: ' + e.message, 'error');
}
});
}
// =========================================================================
// INICIALIZACION
// =========================================================================
/**
* Inicializa el sistema
*
* ESTRATEGIA v2.2 (basada en documentacion oficial de Google):
* - Los slots NO estan ocultos inicialmente (Google puede no ejecutar requests para slots ocultos)
* - Usamos Intersection Observer con rootMargin grande (600px) para precargar
* - Google automaticamente oculta slots unfilled via CSS: ins[data-ad-status="unfilled"]
* - Nuestro CSS colapsa el contenedor .roi-ad-slot cuando el ins tiene unfilled
* - Esto funciona MEJOR que eager loading porque no satura AdSense con requests simultaneos
*/
function init() {
// Siempre configurar listener para ads dinamicos
setupDynamicAdsListener();
debugLog('Listener dinamico configurado');
// Verificar si delay esta habilitado globalmente
if (!window.roiAdsenseDelayed) {
debugLog('Delay global no habilitado');
return;
}
debugLog('Inicializando AdSense Lazy Loader v2.2 (IO + Google Official CSS)');
debugLog('Config: lazyEnabled=' + CONFIG.lazyEnabled + ', rootMargin=' + CONFIG.rootMargin + ', fillTimeout=' + CONFIG.fillTimeout);
// Decidir modo de operacion
if (!CONFIG.lazyEnabled) {
debugLog('Lazy loading deshabilitado, usando modo legacy');
initLegacyMode();
return;
}
// Verificar soporte para Intersection Observer
if (!hasIntersectionObserverSupport()) {
debugLog('Sin soporte IO, usando modo legacy', 'warn');
initLegacyMode();
return;
}
// Inicializar Intersection Observer
if (!initIntersectionObserver()) {
debugLog('Fallo inicializando IO, usando modo legacy', 'warn');
initLegacyMode();
return;
}
// Esperar a que el DOM este listo y observar slots
if (document.readyState === 'interactive' || document.readyState === 'complete') {
observeAllSlots();
} else {
document.addEventListener('DOMContentLoaded', function() {
observeAllSlots();
});
}
}
// Iniciar
init(); init();
})(); })();

View File

@@ -0,0 +1,306 @@
/**
* Cargador Retrasado de AdSense
*
* Este script retrasa la carga de Google AdSense hasta que haya interacción
* del usuario o se cumpla un timeout, mejorando el rendimiento de carga inicial.
*
* @package ROI_Theme
* @since 1.0.0
*/
(function() {
'use strict';
// Configuración
const CONFIG = {
timeout: 5000, // Timeout de fallback en milisegundos
loadedClass: 'adsense-loaded',
debug: true // TEMPORAL: Habilitado para diagnóstico
};
// Estado
let adsenseLoaded = false;
let loadTimeout = null;
/**
* Registra mensajes de debug si el modo debug está habilitado
* @param {string} message - El mensaje a registrar
*/
function debugLog(message) {
if (CONFIG.debug && typeof console !== 'undefined') {
console.log('[AdSense Loader] ' + message);
}
}
/**
* Carga los scripts de AdSense e inicializa los ads
*/
function loadAdSense() {
// Prevenir múltiples cargas
if (adsenseLoaded) {
debugLog('AdSense ya fue cargado, omitiendo...');
return;
}
adsenseLoaded = true;
debugLog('Cargando scripts de AdSense...');
// Limpiar el timeout si existe
if (loadTimeout) {
clearTimeout(loadTimeout);
loadTimeout = null;
}
// Remover event listeners para prevenir múltiples triggers
removeEventListeners();
// 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();
// Agregar clase loaded al body
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(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');
// Copiar atributos
if (oldScript.src) {
newScript.src = oldScript.src;
}
// Establecer atributo async
newScript.async = true;
// Copiar crossorigin si está presente
if (oldScript.getAttribute('crossorigin')) {
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);
});
}
/**
* Ejecuta scripts de push de AdSense retrasados
*/
function executeAdSensePushScripts() {
const delayedPushScripts = document.querySelectorAll('script[data-adsense-push]');
if (delayedPushScripts.length === 0) {
debugLog('No se encontraron scripts de push retrasados de AdSense');
return;
}
debugLog('Se encontraron ' + delayedPushScripts.length + ' script(s) de push retrasado(s)');
// Inicializar array adsbygoogle si no existe
window.adsbygoogle = window.adsbygoogle || [];
delayedPushScripts.forEach(function(oldScript) {
const scriptContent = oldScript.innerHTML;
// Crear y ejecutar nuevo script
const newScript = document.createElement('script');
newScript.innerHTML = scriptContent;
newScript.type = 'text/javascript';
// Reemplazar script viejo con el nuevo
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
/**
* Manejador de eventos para interacciones del usuario
*/
function handleUserInteraction() {
debugLog('Interacción del usuario detectada');
loadAdSense();
}
/**
* Remueve todos los event listeners
*/
function removeEventListeners() {
window.removeEventListener('scroll', handleUserInteraction, { passive: true });
window.removeEventListener('mousemove', handleUserInteraction, { passive: true });
window.removeEventListener('touchstart', handleUserInteraction, { passive: true });
window.removeEventListener('click', handleUserInteraction, { passive: true });
window.removeEventListener('keydown', handleUserInteraction, { passive: true });
}
/**
* Agrega event listeners para interacciones del usuario
*/
function addEventListeners() {
debugLog('Agregando event listeners para interacción del usuario');
// Evento de scroll - cargar en primer scroll
window.addEventListener('scroll', handleUserInteraction, { passive: true, once: true });
// Movimiento de mouse - cargar cuando el usuario mueve el mouse
window.addEventListener('mousemove', handleUserInteraction, { passive: true, once: true });
// Eventos táctiles - cargar en primer toque (móviles)
window.addEventListener('touchstart', handleUserInteraction, { passive: true, once: true });
// Eventos de click - cargar en primer click
window.addEventListener('click', handleUserInteraction, { passive: true, once: true });
// Eventos de teclado - cargar en primera pulsación de tecla
window.addEventListener('keydown', handleUserInteraction, { passive: true, once: true });
}
/**
* Establece timeout de fallback para cargar AdSense después del tiempo especificado
*/
function setTimeoutFallback() {
debugLog('Estableciendo timeout de fallback (' + CONFIG.timeout + 'ms)');
loadTimeout = setTimeout(function() {
debugLog('Timeout alcanzado, cargando AdSense');
loadAdSense();
}, 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');
return;
}
debugLog('Inicializando cargador retrasado de AdSense');
// Verificar si la página ya está interactiva o completa
if (document.readyState === 'interactive' || document.readyState === 'complete') {
debugLog('Página ya cargada, iniciando listeners');
addEventListeners();
setTimeoutFallback();
} else {
// Esperar a que el DOM esté listo
debugLog('Esperando a DOMContentLoaded');
document.addEventListener('DOMContentLoaded', function() {
debugLog('DOMContentLoaded disparado');
addEventListeners();
setTimeoutFallback();
});
}
}
// Iniciar inicialización
init();
})();

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

View File

@@ -41,7 +41,7 @@ define('ROI_DEFERRED_CSS', [
'roi-utilities', 'roi-utilities',
'roi-accessibility', 'roi-accessibility',
'roi-responsive', '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', 'roi-bootstrap',
get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css', get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css',
array('roi-fonts'), 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 'print' // DIFERIDO - critical CSS inline evita CLS
); );
// Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed) // Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed)
// Original: 211 KB (2050 iconos) -> Subset: 13 KB (104 iconos) = 94% reduccion // 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( wp_enqueue_style(
'bootstrap-icons', 'bootstrap-icons',
get_template_directory_uri() . '/Assets/Vendor/bootstrap-icons-subset.min.css', get_template_directory_uri() . '/Assets/Vendor/bootstrap-icons-subset.min.css',
array('roi-bootstrap'), array('roi-bootstrap'),
ROI_VERSION, ROI_VERSION,
'print' 'all' // CRITICO - no diferir para evitar parpadeo de iconos
); );
// Variables CSS del Template RDash - DIFERIDO // Variables CSS del Template RDash - DIFERIDO
@@ -489,6 +489,25 @@ function roi_enqueue_adsense_loader() {
'strategy' => 'defer', 'strategy' => 'defer',
) )
); );
// Pasar configuración de lazy loading a JavaScript
// Nota: wp_add_inline_script funciona mejor con strategy => 'defer' que wp_localize_script
$lazy_enabled = roi_get_component_setting('adsense-placement', 'behavior', 'lazy_loading_enabled', true);
$lazy_rootmargin = roi_get_component_setting('adsense-placement', 'behavior', 'lazy_rootmargin', '200');
$lazy_fill_timeout = roi_get_component_setting('adsense-placement', 'behavior', 'lazy_fill_timeout', '5000');
$config = array(
'lazyEnabled' => filter_var($lazy_enabled, FILTER_VALIDATE_BOOLEAN),
'rootMargin' => (int) $lazy_rootmargin . 'px 0px',
'fillTimeout' => (int) $lazy_fill_timeout,
'debug' => defined('WP_DEBUG') && WP_DEBUG ? true : false,
);
wp_add_inline_script(
'roi-adsense-loader',
'window.roiAdsenseConfig = ' . wp_json_encode($config) . ';',
'before'
);
} }
add_action('wp_enqueue_scripts', 'roi_enqueue_adsense_loader', 10); add_action('wp_enqueue_scripts', 'roi_enqueue_adsense_loader', 10);

View File

@@ -50,6 +50,13 @@ function roi_get_featured_image($post_id = null, $size = 'roi-featured-large', $
return ''; // No placeholder - retornar vacío 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 // Obtener tipo de post
$post_type = get_post_type($post_id); $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 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 // Obtener la imagen con clases Bootstrap
$image = get_the_post_thumbnail($post_id, 'roi-featured-medium', array( $image = get_the_post_thumbnail($post_id, 'roi-featured-medium', array(
'class' => 'img-fluid post-thumbnail', '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 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 // Obtener la imagen
$image = get_the_post_thumbnail($post_id, 'roi-thumbnail', array( $image = get_the_post_thumbnail($post_id, 'roi-thumbnail', array(
'class' => 'img-fluid post-thumbnail-small', 'class' => 'img-fluid post-thumbnail-small',
@@ -287,6 +308,13 @@ function roi_should_show_featured_image($post_id = null) {
return false; 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 // Obtener tipo de post
$post_type = get_post_type($post_id); $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 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 // Obtener URL de la imagen
$image_url = get_the_post_thumbnail_url($post_id, $size); $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 * 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 * @since 1.0.0
* @deprecated 1.0.1 Conflicto con W3TC page cache - Issue #XX
*/ */
function roi_enable_gzip_compression() { function roi_enable_gzip_compression() {
// Solo en frontend // DESACTIVADO - No hacer nada
if ( is_admin() ) { // La compresión la maneja nginx, no PHP
return; return;
} }
// DESACTIVADO - Conflicto con W3 Total Cache page cache
// Verificar si GZIP ya está habilitado // add_action( 'template_redirect', 'roi_enable_gzip_compression', 0 );
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 );
/** /**
* ============================================================================ * ============================================================================

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

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Application\UseCases;
use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
/**
* Use Case para verificar visibilidad de anuncios AdSense.
*
* Application Layer - Orquesta el checker de domain.
* No contiene logica de negocio, solo coordina.
*
* @package ROITheme\Public\AdsensePlacement\Application\UseCases
*/
final class CheckAdsenseVisibilityUseCase
{
public function __construct(
private AdsenseVisibilityCheckerInterface $visibilityChecker
) {
}
/**
* Ejecuta la verificacion de visibilidad.
*
* @param int $postId ID del post (0 para paginas de archivo/home)
* @param UserContext $userContext Contexto del usuario
* @return VisibilityDecision Decision de visibilidad
*/
public function execute(int $postId, UserContext $userContext): VisibilityDecision
{
return $this->visibilityChecker->check($postId, $userContext);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\Contracts;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
/**
* Interface para el servicio que evalua visibilidad de anuncios.
*
* Domain Layer - Define el contrato sin dependencias de infraestructura.
*
* @package ROITheme\Public\AdsensePlacement\Domain\Contracts
*/
interface AdsenseVisibilityCheckerInterface
{
/**
* Evalua si los anuncios deben mostrarse para el contexto dado.
*
* @param int $postId ID del post (0 para paginas de archivo/home)
* @param UserContext $userContext Contexto del usuario
* @return VisibilityDecision Decision con razones y cache
*/
public function check(int $postId, UserContext $userContext): VisibilityDecision;
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que encapsula la configuracion de AdSense relevante para visibilidad.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class AdsenseSettings
{
/**
* @param bool $isEnabled Si AdSense esta activo globalmente
* @param bool $showOnDesktop Si se muestra en desktop
* @param bool $showOnMobile Si se muestra en mobile
* @param bool $hideForLoggedIn Si se oculta para usuarios logueados
* @param bool $javascriptFirstMode Si el modo JS-first esta activo
* @param array<int> $excludedCategoryIds IDs de categorias excluidas
* @param array<int> $excludedPostIds IDs de posts excluidos
* @param array<string> $excludedPostTypes Post types excluidos
*/
public function __construct(
private bool $isEnabled,
private bool $showOnDesktop,
private bool $showOnMobile,
private bool $hideForLoggedIn,
private bool $javascriptFirstMode,
private array $excludedCategoryIds = [],
private array $excludedPostIds = [],
private array $excludedPostTypes = []
) {
}
public function isEnabled(): bool
{
return $this->isEnabled;
}
public function showOnDesktop(): bool
{
return $this->showOnDesktop;
}
public function showOnMobile(): bool
{
return $this->showOnMobile;
}
public function hideForLoggedIn(): bool
{
return $this->hideForLoggedIn;
}
public function isJavascriptFirstMode(): bool
{
return $this->javascriptFirstMode;
}
/**
* @return array<int>
*/
public function getExcludedCategoryIds(): array
{
return $this->excludedCategoryIds;
}
/**
* @return array<int>
*/
public function getExcludedPostIds(): array
{
return $this->excludedPostIds;
}
/**
* @return array<string>
*/
public function getExcludedPostTypes(): array
{
return $this->excludedPostTypes;
}
public function isPostExcluded(int $postId): bool
{
return in_array($postId, $this->excludedPostIds, true);
}
public function isPostTypeExcluded(string $postType): bool
{
return in_array($postType, $this->excludedPostTypes, true);
}
public function isCategoryExcluded(int $categoryId): bool
{
return in_array($categoryId, $this->excludedCategoryIds, true);
}
/**
* Crea instancia desde array de configuracion de BD.
*
* @param array<string, array<string, mixed>> $settings Configuracion agrupada
*/
public static function fromArray(array $settings): self
{
$visibility = $settings['visibility'] ?? [];
$behavior = $settings['behavior'] ?? [];
$forms = $settings['forms'] ?? [];
return new self(
isEnabled: (bool) ($visibility['is_enabled'] ?? false),
showOnDesktop: (bool) ($visibility['show_on_desktop'] ?? true),
showOnMobile: (bool) ($visibility['show_on_mobile'] ?? true),
hideForLoggedIn: (bool) ($visibility['hide_for_logged_in'] ?? false),
javascriptFirstMode: (bool) ($behavior['javascript_first_mode'] ?? false),
excludedCategoryIds: self::parseIds($forms['exclude_categories'] ?? ''),
excludedPostIds: self::parseIds($forms['exclude_post_ids'] ?? ''),
excludedPostTypes: self::parsePostTypes($forms['exclude_post_types'] ?? '')
);
}
/**
* Parsea string de IDs separados por coma a array de enteros.
*
* @return array<int>
*/
private static function parseIds(string $value): array
{
if (empty($value)) {
return [];
}
return array_filter(
array_map(
static fn(string $id): int => (int) trim($id),
explode(',', $value)
),
static fn(int $id): bool => $id > 0
);
}
/**
* Parsea string de post types separados por coma.
*
* @return array<string>
*/
private static function parsePostTypes(string $value): array
{
if (empty($value)) {
return [];
}
return array_filter(
array_map(
static fn(string $type): string => trim($type),
explode(',', $value)
),
static fn(string $type): bool => $type !== ''
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que encapsula el contexto del usuario para decisiones de visibilidad.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class UserContext
{
/**
* @param bool $isLoggedIn Si el usuario tiene sesion activa
* @param bool $isMobile Si el dispositivo es movil (viewport < 992px)
* @param array<int> $userRoles IDs de roles del usuario
*/
public function __construct(
private bool $isLoggedIn,
private bool $isMobile,
private array $userRoles = []
) {
}
public function isLoggedIn(): bool
{
return $this->isLoggedIn;
}
public function isMobile(): bool
{
return $this->isMobile;
}
public function isDesktop(): bool
{
return !$this->isMobile;
}
/**
* @return array<int>
*/
public function getUserRoles(): array
{
return $this->userRoles;
}
public function hasRole(int $roleId): bool
{
return in_array($roleId, $this->userRoles, true);
}
/**
* Crea instancia desde array (para deserializacion).
*
* @param array{is_logged_in: bool, is_mobile: bool, user_roles?: array<int>} $data
*/
public static function fromArray(array $data): self
{
return new self(
isLoggedIn: $data['is_logged_in'] ?? false,
isMobile: $data['is_mobile'] ?? false,
userRoles: $data['user_roles'] ?? []
);
}
/**
* Serializa a array para respuesta JSON.
*
* @return array{is_logged_in: bool, is_mobile: bool, user_roles: array<int>}
*/
public function toArray(): array
{
return [
'is_logged_in' => $this->isLoggedIn,
'is_mobile' => $this->isMobile,
'user_roles' => $this->userRoles,
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que representa la decision de mostrar o no anuncios.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
* El timestamp se inyecta en toArray() para mantener Domain puro.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class VisibilityDecision
{
private const DEFAULT_CACHE_SECONDS = 300; // 5 minutos
/**
* @param bool $showAds Si se deben mostrar los anuncios
* @param array<string> $reasons Razones de la decision (para debugging)
* @param int $cacheSeconds Tiempo de cache en segundos
*/
public function __construct(
private bool $showAds,
private array $reasons = [],
private int $cacheSeconds = self::DEFAULT_CACHE_SECONDS
) {
}
public function shouldShowAds(): bool
{
return $this->showAds;
}
/**
* @return array<string>
*/
public function getReasons(): array
{
return $this->reasons;
}
public function getCacheSeconds(): int
{
return $this->cacheSeconds;
}
/**
* Factory: Crea decision positiva (mostrar ads).
*/
public static function show(int $cacheSeconds = self::DEFAULT_CACHE_SECONDS): self
{
return new self(
showAds: true,
reasons: ['all_conditions_passed'],
cacheSeconds: $cacheSeconds
);
}
/**
* Factory: Crea decision negativa (ocultar ads).
*
* @param array<string> $reasons
*/
public static function hide(array $reasons, int $cacheSeconds = self::DEFAULT_CACHE_SECONDS): self
{
return new self(
showAds: false,
reasons: $reasons,
cacheSeconds: $cacheSeconds
);
}
/**
* Serializa a array para respuesta JSON.
*
* El timestamp se inyecta aqui (no en constructor) para mantener Domain puro.
*
* @param int $timestamp Unix timestamp actual
* @return array{show_ads: bool, reasons: array<string>, cache_seconds: int, timestamp: int}
*/
public function toArray(int $timestamp): array
{
return [
'show_ads' => $this->showAds,
'reasons' => $this->reasons,
'cache_seconds' => $this->cacheSeconds,
'timestamp' => $timestamp,
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress;
use ROITheme\Public\AdsensePlacement\Application\UseCases\CheckAdsenseVisibilityUseCase;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST Controller para el endpoint de visibilidad de AdSense.
*
* Infrastructure Layer - Maneja HTTP y traduce a/desde Domain.
*
* Endpoint: GET /wp-json/roi-theme/v1/adsense-placement/visibility
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress
*/
final class AdsenseVisibilityController
{
private const NAMESPACE = 'roi-theme/v1';
private const ROUTE = '/adsense-placement/visibility';
private const NONCE_ACTION = 'roi_adsense_visibility';
public function __construct(
private CheckAdsenseVisibilityUseCase $useCase
) {
}
/**
* Registra la ruta REST del endpoint.
*/
public function registerRoutes(): void
{
register_rest_route(self::NAMESPACE, self::ROUTE, [
'methods' => 'GET',
'callback' => [$this, 'handleRequest'],
'permission_callback' => '__return_true',
'args' => [
'post_id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
// IMPORTANTE: postId=0 es valido (paginas de archivo, home, etc.)
'validate_callback' => static fn($value): bool => $value >= 0,
],
'nonce' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
}
/**
* Maneja la peticion REST.
*/
public function handleRequest(WP_REST_Request $request): WP_REST_Response
{
$this->sendNoCacheHeaders();
// Validar nonce si se proporciona
$nonce = $request->get_param('nonce');
if ($nonce !== null && !wp_verify_nonce($nonce, self::NONCE_ACTION)) {
return new WP_REST_Response([
'show_ads' => false,
'reasons' => ['invalid_nonce'],
'cache_seconds' => 0,
'timestamp' => time(),
], 403);
}
$postId = (int) $request->get_param('post_id');
$userContext = $this->buildUserContext();
$decision = $this->useCase->execute($postId, $userContext);
// El timestamp se inyecta aqui (Infrastructure) para mantener Domain puro
return new WP_REST_Response($decision->toArray(time()), 200);
}
/**
* Construye UserContext desde el estado actual de WordPress.
*/
private function buildUserContext(): UserContext
{
$isLoggedIn = is_user_logged_in();
$userRoles = [];
if ($isLoggedIn) {
$user = wp_get_current_user();
$userRoles = array_map(
static fn(string $role): int => self::roleToId($role),
$user->roles
);
}
// isMobile se determina por el cliente, no el servidor
// El cliente enviara esta info, pero por defecto asumimos false
$isMobile = false;
return new UserContext(
isLoggedIn: $isLoggedIn,
isMobile: $isMobile,
userRoles: $userRoles
);
}
/**
* Convierte nombre de rol a ID numerico para consistencia.
*/
private static function roleToId(string $role): int
{
$roleMap = [
'administrator' => 1,
'editor' => 2,
'author' => 3,
'contributor' => 4,
'subscriber' => 5,
];
return $roleMap[$role] ?? 0;
}
/**
* Envia headers para prevenir cache en proxies/CDNs.
*/
private function sendNoCacheHeaders(): void
{
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
header('Vary: Cookie');
}
/**
* Obtiene la accion del nonce para generacion en frontend.
*/
public static function getNonceAction(): string
{
return self::NONCE_ACTION;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Providers;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Infrastructure\Services\AdsenseVisibilityChecker;
use ROITheme\Public\AdsensePlacement\Application\UseCases\CheckAdsenseVisibilityUseCase;
use ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress\AdsenseVisibilityController;
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsenseAssetsEnqueuer;
/**
* Service Provider para el sistema JavaScript-First de AdSense.
*
* Registra todas las dependencias y hooks necesarios.
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Providers
*/
final class AdsenseJavascriptFirstServiceProvider
{
private ?AdsenseVisibilityCheckerInterface $visibilityChecker = null;
private ?CheckAdsenseVisibilityUseCase $useCase = null;
private ?AdsenseVisibilityController $controller = null;
private ?AdsenseAssetsEnqueuer $enqueuer = null;
public function __construct(
private DIContainer $container
) {
}
/**
* Registra los servicios en el contenedor.
*
* Llamar en after_setup_theme.
*/
public function register(): void
{
// Los servicios se crean lazy en boot()
}
/**
* Inicializa los hooks de WordPress.
*
* Llamar en init.
*/
public function boot(): void
{
// Registrar REST API endpoint
add_action('rest_api_init', function(): void {
$this->getController()->registerRoutes();
});
// Registrar enqueue de assets
$this->getEnqueuer()->register();
}
/**
* Obtiene el checker de visibilidad (lazy initialization).
*/
public function getVisibilityChecker(): AdsenseVisibilityCheckerInterface
{
if ($this->visibilityChecker === null) {
$this->visibilityChecker = new AdsenseVisibilityChecker(
$this->container->getComponentSettingsRepository()
);
}
return $this->visibilityChecker;
}
/**
* Obtiene el use case (lazy initialization).
*/
public function getUseCase(): CheckAdsenseVisibilityUseCase
{
if ($this->useCase === null) {
$this->useCase = new CheckAdsenseVisibilityUseCase(
$this->getVisibilityChecker()
);
}
return $this->useCase;
}
/**
* Obtiene el controller REST (lazy initialization).
*/
public function getController(): AdsenseVisibilityController
{
if ($this->controller === null) {
$this->controller = new AdsenseVisibilityController(
$this->getUseCase()
);
}
return $this->controller;
}
/**
* Obtiene el enqueuer de assets (lazy initialization).
*/
public function getEnqueuer(): AdsenseAssetsEnqueuer
{
if ($this->enqueuer === null) {
$this->enqueuer = new AdsenseAssetsEnqueuer(
$this->container->getComponentSettingsRepository()
);
}
return $this->enqueuer;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Services;
use ROITheme\Public\AdsensePlacement\Domain\Contracts\AdsenseVisibilityCheckerInterface;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\AdsenseSettings;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* Implementacion del checker de visibilidad de AdSense.
*
* Infrastructure Layer - Accede a BD via repository.
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Services
*/
final class AdsenseVisibilityChecker implements AdsenseVisibilityCheckerInterface
{
private const COMPONENT_NAME = 'adsense-placement';
private const CACHE_SECONDS_SHOW = 300; // 5 min cuando se muestran ads
private const CACHE_SECONDS_HIDE = 600; // 10 min cuando se ocultan (menos volatil)
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {
}
public function check(int $postId, UserContext $userContext): VisibilityDecision
{
// Cargar configuracion desde BD
$rawSettings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
$settings = AdsenseSettings::fromArray($rawSettings);
// Evaluar reglas de visibilidad
$reasons = [];
// 1. AdSense desactivado globalmente
if (!$settings->isEnabled()) {
return VisibilityDecision::hide(['adsense_disabled'], self::CACHE_SECONDS_HIDE);
}
// 2. JavaScript-First Mode desactivado (usar PHP legacy)
if (!$settings->isJavascriptFirstMode()) {
return VisibilityDecision::hide(['javascript_first_disabled'], self::CACHE_SECONDS_HIDE);
}
// 3. Ocultar para usuarios logueados
if ($settings->hideForLoggedIn() && $userContext->isLoggedIn()) {
$reasons[] = 'user_logged_in';
}
// 4. Verificar dispositivo
if ($userContext->isMobile() && !$settings->showOnMobile()) {
$reasons[] = 'mobile_disabled';
}
if ($userContext->isDesktop() && !$settings->showOnDesktop()) {
$reasons[] = 'desktop_disabled';
}
// 5. Verificar exclusiones de post (solo si postId > 0)
if ($postId > 0) {
if ($settings->isPostExcluded($postId)) {
$reasons[] = 'post_excluded';
}
// Verificar categorias del post
$postCategories = $this->getPostCategoryIds($postId);
foreach ($postCategories as $catId) {
if ($settings->isCategoryExcluded($catId)) {
$reasons[] = 'category_excluded';
break;
}
}
// Verificar post type
$postType = $this->getPostType($postId);
if ($postType !== '' && $settings->isPostTypeExcluded($postType)) {
$reasons[] = 'post_type_excluded';
}
}
// Decision final
if (count($reasons) > 0) {
return VisibilityDecision::hide($reasons, self::CACHE_SECONDS_HIDE);
}
return VisibilityDecision::show(self::CACHE_SECONDS_SHOW);
}
/**
* Obtiene IDs de categorias de un post.
*
* @return array<int>
*/
private function getPostCategoryIds(int $postId): array
{
$categories = get_the_category($postId);
if (!is_array($categories)) {
return [];
}
return array_map(
static fn($cat): int => (int) $cat->term_id,
$categories
);
}
/**
* Obtiene el post type de un post.
*/
private function getPostType(int $postId): string
{
$postType = get_post_type($postId);
return $postType !== false ? $postType : '';
}
}

View File

@@ -9,13 +9,30 @@ use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
* Inyecta anuncios dentro del contenido del post * Inyecta anuncios dentro del contenido del post
* via filtro the_content * via filtro the_content
* *
* Soporta: * Soporta dos modos:
* - Modo aleatorio (random) con posiciones variables * - Solo parrafos: Logica clasica solo con parrafos (usa config de behavior)
* - Configuracion de 1-8 ads maximo * - Avanzado: Multiples tipos de elementos (H2, H3, p, img, lists, blockquotes, tables)
* - Espacio minimo entre anuncios *
* 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 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( public function __construct(
private array $settings, private array $settings,
private AdsensePlacementRenderer $renderer private AdsensePlacementRenderer $renderer
@@ -25,18 +42,37 @@ final class ContentAdInjector
* Filtra the_content para insertar anuncios * Filtra the_content para insertar anuncios
*/ */
public function inject(string $content): string 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)) { if (!($this->settings['behavior']['post_content_enabled'] ?? false)) {
return $content; return $content;
} }
// Verificar longitud minima // Obtener configuracion de behavior (modo solo parrafos)
$minLength = (int)($this->settings['forms']['min_content_length'] ?? 500);
if (strlen(strip_tags($content)) < $minLength) {
return $content;
}
// Obtener configuracion
$minAds = (int)($this->settings['behavior']['post_content_min_ads'] ?? 1); $minAds = (int)($this->settings['behavior']['post_content_min_ads'] ?? 1);
$maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 3); $maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 3);
$afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3); $afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3);
@@ -58,7 +94,7 @@ final class ContentAdInjector
} }
// Calcular posiciones de insercion // Calcular posiciones de insercion
$adPositions = $this->calculateAdPositions( $adPositions = $this->calculateParagraphsOnlyPositions(
$totalParagraphs, $totalParagraphs,
$afterParagraphs, $afterParagraphs,
$minBetween, $minBetween,
@@ -72,9 +108,452 @@ final class ContentAdInjector
} }
// Reconstruir contenido con anuncios insertados // 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 * 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 * @return int[] Indices de parrafos despues de los cuales insertar ads
*/ */
private function calculateAdPositions( private function calculateParagraphsOnlyPositions(
int $totalParagraphs, int $totalParagraphs,
int $afterFirst, int $afterFirst,
int $minBetween, int $minBetween,
@@ -117,7 +596,6 @@ final class ContentAdInjector
): array { ): array {
// Calcular posiciones disponibles respetando el espacio minimo // Calcular posiciones disponibles respetando el espacio minimo
$availablePositions = []; $availablePositions = [];
$lastPosition = $afterFirst; // Primera posicion fija
// La primera posicion siempre es despues del parrafo indicado // La primera posicion siempre es despues del parrafo indicado
if ($afterFirst < $totalParagraphs) { 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 = ''; $newContent = '';
$adsInserted = 0; $adsInserted = 0;

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Public\AdsensePlacement\Infrastructure\Api\WordPress\AdsenseVisibilityController;
/**
* Encola los assets JavaScript para el modo JavaScript-First de AdSense.
*
* Infrastructure Layer - Integra con WordPress asset system.
*
* @package ROITheme\Public\AdsensePlacement\Infrastructure\Ui
*/
final class AdsenseAssetsEnqueuer
{
private const COMPONENT_NAME = 'adsense-placement';
private const SCRIPT_HANDLE = 'roi-adsense-visibility';
private const SCRIPT_VERSION = '1.0.0';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
) {
}
/**
* Registra el hook para encolar scripts.
*/
public function register(): void
{
add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
}
/**
* Encola el script de visibilidad si el modo JS-First esta activo.
*/
public function enqueueScripts(): void
{
// Verificar si el modo JS-First esta activo
if (!$this->isJavascriptFirstModeEnabled()) {
return;
}
// Solo en frontend, no en admin
if (is_admin()) {
return;
}
$scriptPath = $this->getScriptPath();
// Verificar que el archivo existe
if (!file_exists($scriptPath)) {
return;
}
$scriptUrl = $this->getScriptUrl();
wp_enqueue_script(
self::SCRIPT_HANDLE,
$scriptUrl,
[], // Sin dependencias
self::SCRIPT_VERSION,
true // En footer
);
// Pasar configuracion al script
wp_localize_script(self::SCRIPT_HANDLE, 'roiAdsenseConfig', $this->getScriptConfig());
}
/**
* Verifica si el modo JavaScript-First esta activo.
*/
private function isJavascriptFirstModeEnabled(): bool
{
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
$isEnabled = (bool) ($settings['visibility']['is_enabled'] ?? false);
$jsFirstMode = (bool) ($settings['behavior']['javascript_first_mode'] ?? false);
return $isEnabled && $jsFirstMode;
}
/**
* Obtiene la ruta fisica del script.
*/
private function getScriptPath(): string
{
return get_template_directory() . '/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js';
}
/**
* Obtiene la URL del script.
*/
private function getScriptUrl(): string
{
return get_template_directory_uri() . '/Public/AdsensePlacement/Infrastructure/Ui/Assets/adsense-visibility.js';
}
/**
* Obtiene la configuracion para pasar al script.
*
* @return array<string, mixed>
*/
private function getScriptConfig(): array
{
$postId = $this->getCurrentPostId();
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
return [
'endpoint' => rest_url('roi-theme/v1/adsense-placement/visibility'),
'postId' => $postId,
'nonce' => wp_create_nonce(AdsenseVisibilityController::getNonceAction()),
'settingsVersion' => $settings['_meta']['version'] ?? '1.0.0',
'debug' => defined('WP_DEBUG') && WP_DEBUG,
'featureEnabled' => true,
'fallbackStrategy' => 'cached-or-show', // cached-or-show | cached-or-hide | always-show
];
}
/**
* Obtiene el ID del post actual (0 si no es un post singular).
*/
private function getCurrentPostId(): int
{
if (is_singular()) {
return get_the_ID() ?: 0;
}
return 0;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui; namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* Renderer para slots de AdSense * Renderer para slots de AdSense
@@ -36,6 +37,11 @@ final class AdsensePlacementRenderer
*/ */
public function renderSlot(array $settings, string $location): string 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 // 1. Validar is_enabled
if (!($settings['visibility']['is_enabled'] ?? false)) { if (!($settings['visibility']['is_enabled'] ?? false)) {
return ''; return '';
@@ -58,18 +64,70 @@ final class AdsensePlacementRenderer
} }
// 4. Generar CSS (usando CSSGeneratorService) // 4. Generar CSS (usando CSSGeneratorService)
$lazyEnabled = ($settings['behavior']['lazy_loading_enabled'] ?? true) === true;
// Estrategia para evitar "flash" de slots vacíos:
//
// PROBLEMA: Si mostramos slots con min-height, hay un "flash" visible
// entre que el slot aparece y AdSense lo marca como unfilled.
//
// SOLUCIÓN: Ocultar slots INICIALMENTE (height:0, overflow:hidden)
// y SOLO mostrarlos cuando tienen data-ad-status="filled".
//
// NOTA: Usamos height:0 + overflow:hidden en vez de display:none
// porque AdSense necesita que el elemento exista en el layout para procesarlo.
// CSS base: slots COLAPSADOS por defecto (sin flash)
$css = $this->cssGenerator->generate( $css = $this->cssGenerator->generate(
".roi-ad-slot", ".roi-ad-slot",
[ [
'display' => 'block',
'width' => '100%', 'width' => '100%',
'min_width' => '300px', 'min_width' => '300px',
'margin_top' => '1.5rem',
'margin_bottom' => '1.5rem',
'text_align' => 'center', 'text_align' => 'center',
'overflow' => 'hidden',
// COLAPSADO por defecto - evita el flash
'height' => '0',
'margin' => '0',
'padding' => '0',
'opacity' => '0',
'transition' => 'height 0.3s ease, margin 0.3s ease, opacity 0.3s ease',
] ]
); );
// SOLO mostrar cuando AdSense confirma que hay anuncio (filled)
// Esto es la clave: el slot permanece oculto hasta confirmación
$css .= $this->cssGenerator->generate(
".roi-ad-slot:has(ins.adsbygoogle[data-ad-status='filled'])",
[
'height' => 'auto',
'margin_top' => '1.5rem',
'margin_bottom' => '1.5rem',
'opacity' => '1',
]
);
// Fallback JS: clase añadida por adsense-loader.js cuando detecta filled
$css .= $this->cssGenerator->generate('.roi-ad-slot.roi-ad-filled', [
'height' => 'auto',
'margin_top' => '1.5rem',
'margin_bottom' => '1.5rem',
'opacity' => '1',
]);
// Slots unfilled permanecen colapsados (ya lo están por defecto)
// Pero añadimos el selector explícito para claridad
$css .= $this->cssGenerator->generate(
"ins.adsbygoogle[data-ad-status='unfilled']",
[
'display' => 'none !important',
]
);
// Fallback para navegadores sin soporte :has() - clase JS
$css .= $this->cssGenerator->generate('.roi-ad-slot.roi-ad-empty', [
'display' => 'none',
]);
// 5. Generar HTML del anuncio // 5. Generar HTML del anuncio
$html = $this->buildAdHTML( $html = $this->buildAdHTML(
$settings, $settings,
@@ -105,15 +163,24 @@ final class AdsensePlacementRenderer
{ {
$locationKey = str_replace('-', '_', $location); $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)) { 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 [ return [
'enabled' => $settings['behavior']['post_content_enabled'] ?? false, 'enabled' => $settings['behavior']['post_content_enabled'] ?? false,
'format' => $settings['behavior']['post_content_format'] ?? 'in-article', '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 // Mapeo de ubicaciones a grupos y campos
$locationMap = [ $locationMap = [
'post_top' => ['group' => 'behavior', 'enabled' => 'post_top_enabled', 'format' => 'post_top_format'], 'post_top' => ['group' => 'behavior', 'enabled' => 'post_top_enabled', 'format' => 'post_top_format'],
@@ -146,6 +213,7 @@ final class AdsensePlacementRenderer
{ {
$publisherId = esc_attr($settings['content']['publisher_id'] ?? ''); $publisherId = esc_attr($settings['content']['publisher_id'] ?? '');
$delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true; $delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true;
$lazyEnabled = ($settings['behavior']['lazy_loading_enabled'] ?? true) === true;
if (empty($publisherId)) { if (empty($publisherId)) {
return ''; return '';
@@ -159,9 +227,10 @@ final class AdsensePlacementRenderer
$scriptType = $delayEnabled ? 'text/plain' : 'text/javascript'; $scriptType = $delayEnabled ? 'text/plain' : 'text/javascript';
$dataAttr = $delayEnabled ? ' data-adsense-push' : ''; $dataAttr = $delayEnabled ? ' data-adsense-push' : '';
$lazyAttr = $lazyEnabled ? ' data-ad-lazy="true"' : '';
$locationClass = 'roi-ad-' . esc_attr(str_replace('_', '-', $location)); $locationClass = 'roi-ad-' . esc_attr(str_replace('_', '-', $location));
return $this->generateAdMarkup($format, $publisherId, $slotId, $locationClass, $visClasses, $scriptType, $dataAttr); return $this->generateAdMarkup($format, $publisherId, $slotId, $locationClass, $visClasses, $scriptType, $dataAttr, $lazyAttr);
} }
/** /**
@@ -202,68 +271,69 @@ final class AdsensePlacementRenderer
string $locationClass, string $locationClass,
string $visClasses, string $visClasses,
string $scriptType, string $scriptType,
string $dataAttr string $dataAttr,
string $lazyAttr = ''
): string { ): string {
$allClasses = trim("{$locationClass} {$visClasses}"); $allClasses = trim("{$locationClass} {$visClasses}");
return match($format) { return match($format) {
'display' => $this->adDisplay($client, $slot, 728, 90, $allClasses, $scriptType, $dataAttr), 'display' => $this->adDisplay($client, $slot, 728, 90, $allClasses, $scriptType, $dataAttr, $lazyAttr),
'display-large' => $this->adDisplay($client, $slot, 970, 250, $allClasses, $scriptType, $dataAttr), 'display-large' => $this->adDisplay($client, $slot, 970, 250, $allClasses, $scriptType, $dataAttr, $lazyAttr),
'display-square' => $this->adDisplay($client, $slot, 300, 250, $allClasses, $scriptType, $dataAttr), 'display-square' => $this->adDisplay($client, $slot, 300, 250, $allClasses, $scriptType, $dataAttr, $lazyAttr),
'in-article' => $this->adInArticle($client, $slot, $allClasses, $scriptType, $dataAttr), 'in-article' => $this->adInArticle($client, $slot, $allClasses, $scriptType, $dataAttr, $lazyAttr),
'autorelaxed' => $this->adAutorelaxed($client, $slot, $allClasses, $scriptType, $dataAttr), 'autorelaxed' => $this->adAutorelaxed($client, $slot, $allClasses, $scriptType, $dataAttr, $lazyAttr),
default => $this->adAuto($client, $slot, $allClasses, $scriptType, $dataAttr), default => $this->adAuto($client, $slot, $allClasses, $scriptType, $dataAttr, $lazyAttr),
}; };
} }
private function adDisplay(string $c, string $s, int $w, int $h, string $cl, string $t, string $a): string private function adDisplay(string $c, string $s, int $w, int $h, string $cl, string $t, string $a, string $lazy = ''): string
{ {
return sprintf( return sprintf(
'<div class="roi-ad-slot %s"> '<div class="roi-ad-slot %s"%s>
<ins class="adsbygoogle" style="display:inline-block;width:%dpx;height:%dpx" <ins class="adsbygoogle" style="display:inline-block;width:%dpx;height:%dpx"
data-ad-client="%s" data-ad-slot="%s"></ins> data-ad-client="%s" data-ad-slot="%s"></ins>
<script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script> <script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>', </div>',
esc_attr($cl), $w, $h, esc_attr($c), esc_attr($s), $t, $a esc_attr($cl), $lazy, $w, $h, esc_attr($c), esc_attr($s), $t, $a
); );
} }
private function adAuto(string $c, string $s, string $cl, string $t, string $a): string private function adAuto(string $c, string $s, string $cl, string $t, string $a, string $lazy = ''): string
{ {
return sprintf( return sprintf(
'<div class="roi-ad-slot %s"> '<div class="roi-ad-slot %s"%s>
<ins class="adsbygoogle" style="display:block;min-height:250px" <ins class="adsbygoogle" style="display:block;min-height:250px"
data-ad-client="%s" data-ad-slot="%s" data-ad-client="%s" data-ad-slot="%s"
data-ad-format="auto" data-full-width-responsive="true"></ins> data-ad-format="auto" data-full-width-responsive="true"></ins>
<script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script> <script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>', </div>',
esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a esc_attr($cl), $lazy, esc_attr($c), esc_attr($s), $t, $a
); );
} }
private function adInArticle(string $c, string $s, string $cl, string $t, string $a): string private function adInArticle(string $c, string $s, string $cl, string $t, string $a, string $lazy = ''): string
{ {
return sprintf( return sprintf(
'<div class="roi-ad-slot %s"> '<div class="roi-ad-slot %s"%s>
<ins class="adsbygoogle" style="display:block;text-align:center;min-height:200px" <ins class="adsbygoogle" style="display:block;text-align:center;min-height:200px"
data-ad-layout="in-article" data-ad-format="fluid" data-ad-layout="in-article" data-ad-format="fluid"
data-ad-client="%s" data-ad-slot="%s"></ins> data-ad-client="%s" data-ad-slot="%s"></ins>
<script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script> <script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>', </div>',
esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a esc_attr($cl), $lazy, esc_attr($c), esc_attr($s), $t, $a
); );
} }
private function adAutorelaxed(string $c, string $s, string $cl, string $t, string $a): string private function adAutorelaxed(string $c, string $s, string $cl, string $t, string $a, string $lazy = ''): string
{ {
return sprintf( return sprintf(
'<div class="roi-ad-slot %s"> '<div class="roi-ad-slot %s"%s>
<ins class="adsbygoogle" style="display:block;min-height:280px" <ins class="adsbygoogle" style="display:block;min-height:280px"
data-ad-format="autorelaxed" data-ad-format="autorelaxed"
data-ad-client="%s" data-ad-slot="%s"></ins> data-ad-client="%s" data-ad-slot="%s"></ins>
<script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script> <script type="%s"%s>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>', </div>',
esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a esc_attr($cl), $lazy, esc_attr($c), esc_attr($s), $t, $a
); );
} }

View File

@@ -0,0 +1,272 @@
/**
* ROI Theme - AdSense JavaScript-First Visibility Controller
*
* Mueve las decisiones de visibilidad de anuncios del servidor (PHP) al cliente (JS)
* para permitir compatibilidad con cache de pagina mientras mantiene personalizacion
* por usuario.
*
* @version 1.0.0
* @see openspec/specs/adsense-javascript-first/spec.md
*/
(function() {
'use strict';
const VERSION = '1.0.0';
const CACHE_KEY = 'roi_adsense_visibility';
const CACHE_VERSION_KEY = 'roi_adsense_settings_version';
// Configuracion inyectada por PHP via wp_localize_script
const config = window.roiAdsenseConfig || {};
/**
* Logger condicional (solo en modo debug)
*/
function log(message, type = 'log') {
if (!config.debug) return;
const prefix = '[ROI AdSense v' + VERSION + ']';
console[type](prefix, message);
}
/**
* Detecta si el dispositivo es movil basado en viewport
*/
function isMobile() {
return window.innerWidth < 992;
}
/**
* Obtiene decision cacheada de localStorage
*/
function getCachedDecision() {
try {
const cached = localStorage.getItem(CACHE_KEY);
const cachedVersion = localStorage.getItem(CACHE_VERSION_KEY);
if (!cached) {
log('No hay decision en cache');
return null;
}
// Invalidar si la version de settings cambio
if (cachedVersion !== config.settingsVersion) {
log('Version de settings cambio, invalidando cache');
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_VERSION_KEY);
return null;
}
const data = JSON.parse(cached);
// Verificar expiracion
const now = Math.floor(Date.now() / 1000);
const expiresAt = data.timestamp + data.cache_seconds;
if (now > expiresAt) {
log('Cache expirado');
localStorage.removeItem(CACHE_KEY);
return null;
}
log('Usando decision cacheada: ' + (data.show_ads ? 'MOSTRAR' : 'OCULTAR'));
return data;
} catch (e) {
log('Error leyendo cache: ' + e.message, 'error');
return null;
}
}
/**
* Guarda decision en localStorage
*/
function cacheDecision(decision) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(decision));
localStorage.setItem(CACHE_VERSION_KEY, config.settingsVersion);
log('Decision cacheada por ' + decision.cache_seconds + 's');
} catch (e) {
log('Error guardando cache: ' + e.message, 'warn');
}
}
/**
* Consulta el endpoint REST para obtener decision de visibilidad
*/
async function fetchVisibilityDecision() {
const url = new URL(config.endpoint);
url.searchParams.append('post_id', config.postId);
if (config.nonce) {
url.searchParams.append('nonce', config.nonce);
}
log('Consultando endpoint: ' + url.toString());
const response = await fetch(url.toString(), {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return await response.json();
}
/**
* Activa los anuncios (muestra placeholders, carga AdSense)
*/
function activateAds() {
log('Activando anuncios');
// Remover clase de oculto de los containers
document.querySelectorAll('.roi-adsense-placeholder').forEach(function(el) {
el.classList.remove('roi-adsense-hidden');
el.classList.add('roi-adsense-active');
});
// Disparar evento para que otros scripts puedan reaccionar
document.dispatchEvent(new CustomEvent('roiAdsenseActivated', {
detail: { version: VERSION }
}));
}
/**
* Desactiva los anuncios (oculta placeholders)
*/
function deactivateAds(reasons) {
log('Desactivando anuncios. Razones: ' + reasons.join(', '));
// Agregar clase de oculto a los containers
document.querySelectorAll('.roi-adsense-placeholder').forEach(function(el) {
el.classList.add('roi-adsense-hidden');
el.classList.remove('roi-adsense-active');
});
// Disparar evento
document.dispatchEvent(new CustomEvent('roiAdsenseDeactivated', {
detail: { reasons: reasons, version: VERSION }
}));
}
/**
* Aplica decision de visibilidad
*/
function applyDecision(decision) {
if (decision.show_ads) {
activateAds();
} else {
deactivateAds(decision.reasons || []);
}
}
/**
* Maneja error segun estrategia de fallback configurada
*/
function handleError(error) {
log('Error: ' + error.message, 'error');
const cached = getCachedDecision();
switch (config.fallbackStrategy) {
case 'cached-or-show':
if (cached) {
log('Usando cache como fallback');
applyDecision(cached);
} else {
log('Sin cache, mostrando ads por defecto (proteger revenue)');
activateAds();
}
break;
case 'cached-or-hide':
if (cached) {
log('Usando cache como fallback');
applyDecision(cached);
} else {
log('Sin cache, ocultando ads por defecto');
deactivateAds(['fallback_no_cache']);
}
break;
case 'always-show':
log('Fallback: siempre mostrar');
activateAds();
break;
default:
log('Estrategia desconocida, mostrando ads');
activateAds();
}
}
/**
* Funcion principal de inicializacion
*/
async function init() {
log('Inicializando...');
// Verificar que el feature este habilitado
if (!config.featureEnabled) {
log('Feature deshabilitado, usando modo legacy', 'warn');
return;
}
// IMPORTANTE: postId = 0 es valido (paginas de archivo, home, etc.)
// Solo validar que endpoint exista y postId no sea undefined/null
if (!config.endpoint || config.postId === undefined || config.postId === null) {
log('Sin endpoint configurado, activando ads', 'warn');
activateAds();
return;
}
// Intentar usar cache primero
const cached = getCachedDecision();
if (cached) {
applyDecision(cached);
return;
}
// Consultar endpoint
try {
const decision = await fetchVisibilityDecision();
log('Respuesta del servidor: ' + JSON.stringify(decision));
// Cachear decision
cacheDecision(decision);
// Aplicar decision
applyDecision(decision);
} catch (error) {
handleError(error);
}
}
// Ejecutar cuando el DOM este listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Exponer API publica para debugging
window.roiAdsenseVisibility = {
version: VERSION,
getConfig: function() { return config; },
getCachedDecision: getCachedDecision,
clearCache: function() {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_VERSION_KEY);
log('Cache limpiado');
},
forceRefresh: async function() {
this.clearCache();
await init();
}
};
})();

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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* ContactFormRenderer - Renderiza formulario de contacto con webhook * ContactFormRenderer - Renderiza formulario de contacto con webhook
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class ContactFormRenderer implements RendererInterface final class ContactFormRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'contact-form';
public function __construct( public function __construct(
private CSSGeneratorInterface $cssGenerator private CSSGeneratorInterface $cssGenerator
) {} ) {}
@@ -34,7 +37,7 @@ final class ContactFormRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return ''; return '';
} }
@@ -67,7 +70,7 @@ final class ContactFormRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'contact-form'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -76,22 +79,6 @@ final class ContactFormRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1; 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 private function getVisibilityClass(array $data): ?string
{ {
$showDesktop = $data['visibility']['show_on_desktop'] ?? true; $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 * Inyecta CSS critico en wp_head
* *
* Prioridades: * Prioridades:
* - P:-2 Font preload (antes de variables)
* - P:-1 Variables CSS (antes de Bootstrap) * - P:-1 Variables CSS (antes de Bootstrap)
* - P:2 Responsive critico (despues de Bootstrap critico) * - P:2 Responsive critico (despues de Bootstrap critico)
* *
* NOTA: Font preload deshabilitado - usando system fonts para CERO flash
*
* @package ROITheme\Public\CriticalCSS\Infrastructure\Services * @package ROITheme\Public\CriticalCSS\Infrastructure\Services
*/ */
final class CriticalCSSInjector final class CriticalCSSInjector
@@ -22,11 +21,23 @@ final class CriticalCSSInjector
private readonly CriticalCSSCacheInterface $cache 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 * Registra hooks de WordPress
*/ */
public function register(): void 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) // Variables CSS: P:-1 (antes de CriticalBootstrapService P:0)
add_action('wp_head', [$this, 'injectVariables'], -1); add_action('wp_head', [$this, 'injectVariables'], -1);
@@ -38,6 +49,25 @@ final class CriticalCSSInjector
add_action('wp_enqueue_scripts', [$this, 'dequeueInlinedCSS'], 999); 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 * 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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/** /**
* CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar * CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar
@@ -27,6 +29,12 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class CtaBoxSidebarRenderer implements RendererInterface 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( public function __construct(
private CSSGeneratorInterface $cssGenerator private CSSGeneratorInterface $cssGenerator
) {} ) {}
@@ -39,7 +47,13 @@ final class CtaBoxSidebarRenderer implements RendererInterface
return ''; 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 ''; return '';
} }
@@ -52,7 +66,7 @@ final class CtaBoxSidebarRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'cta-box-sidebar'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -60,22 +74,6 @@ final class CtaBoxSidebarRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true; 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 private function generateCSS(array $data): string
{ {
$colors = $data['colors'] ?? []; $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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/** /**
* Class CtaLetsTalkRenderer * Class CtaLetsTalkRenderer
@@ -34,6 +36,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class CtaLetsTalkRenderer implements RendererInterface final class CtaLetsTalkRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'cta-lets-talk';
/** /**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS * @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/ */
@@ -54,7 +58,12 @@ final class CtaLetsTalkRenderer implements RendererInterface
} }
// Validar visibilidad por página // 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 ''; return '';
} }
@@ -77,7 +86,7 @@ final class CtaLetsTalkRenderer implements RendererInterface
*/ */
public function supports(string $componentType): bool 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; 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 * 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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; 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 * CtaPostRenderer - Renderiza CTA promocional debajo del contenido
@@ -22,6 +24,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class CtaPostRenderer implements RendererInterface final class CtaPostRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'cta-post';
public function __construct( public function __construct(
private CSSGeneratorInterface $cssGenerator private CSSGeneratorInterface $cssGenerator
) {} ) {}
@@ -34,7 +38,12 @@ final class CtaPostRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
// Validar visibilidad por usuario logueado
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
return ''; return '';
} }
@@ -46,7 +55,7 @@ final class CtaPostRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'cta-post'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -55,22 +64,6 @@ final class CtaPostRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1; 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 private function generateCSS(array $data): string
{ {
$colors = $data['colors'] ?? []; $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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* FeaturedImageRenderer - Renderiza la imagen destacada del post * FeaturedImageRenderer - Renderiza la imagen destacada del post
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class FeaturedImageRenderer implements RendererInterface final class FeaturedImageRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'featured-image';
public function __construct( public function __construct(
private CSSGeneratorInterface $cssGenerator private CSSGeneratorInterface $cssGenerator
) {} ) {}
@@ -39,7 +42,7 @@ final class FeaturedImageRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return ''; return '';
} }
@@ -63,7 +66,7 @@ final class FeaturedImageRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'featured-image'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -71,25 +74,24 @@ final class FeaturedImageRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true; 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 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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* FooterRenderer - Renderiza el footer del sitio * FooterRenderer - Renderiza el footer del sitio
@@ -34,9 +35,14 @@ final class FooterRenderer implements RendererInterface
public function render(Component $component): string 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(); $data = $component->getData();
// Validar visibilidad // Validar visibilidad básica
$visibility = $data['visibility'] ?? []; $visibility = $data['visibility'] ?? [];
if (!($visibility['is_enabled'] ?? true)) { if (!($visibility['is_enabled'] ?? true)) {
return ''; return '';

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Hero\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface; use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* Class HeroRenderer * Class HeroRenderer
@@ -33,6 +34,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class HeroRenderer implements RendererInterface final class HeroRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'hero';
/** /**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS * @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/ */
@@ -48,7 +51,7 @@ final class HeroRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return ''; return '';
} }
@@ -68,7 +71,7 @@ final class HeroRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'hero'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -76,24 +79,6 @@ final class HeroRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true; 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 * Generar CSS usando CSSGeneratorService
* *
@@ -148,6 +133,9 @@ final class HeroRenderer implements RendererInterface
'padding' => "{$paddingVertical} 0", 'padding' => "{$paddingVertical} 0",
'margin-bottom' => $marginBottom, 'margin-bottom' => $marginBottom,
'min-height' => $minHeight, 'min-height' => $minHeight,
'display' => 'flex',
'align-items' => 'center',
'justify-content' => 'center',
]); ]);
$cssRules[] = $this->cssGenerator->generate('.hero-section__title', [ $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\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface; use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use Walker_Nav_Menu; use Walker_Nav_Menu;
/** /**
@@ -28,6 +29,8 @@ use Walker_Nav_Menu;
*/ */
final class NavbarRenderer implements RendererInterface final class NavbarRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'navbar';
/** /**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS * @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/ */
@@ -43,6 +46,10 @@ final class NavbarRenderer implements RendererInterface
return ''; return '';
} }
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return '';
}
$html = $this->buildMenu($data); $html = $this->buildMenu($data);
// Si is_critical=true, CSS ya fue inyectado en <head> por CriticalCSSService // 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 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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* RelatedPostRenderer - Renderiza seccion de posts relacionados * RelatedPostRenderer - Renderiza seccion de posts relacionados
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class RelatedPostRenderer implements RendererInterface final class RelatedPostRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'related-post';
public function __construct( public function __construct(
private CSSGeneratorInterface $cssGenerator private CSSGeneratorInterface $cssGenerator
) {} ) {}
@@ -34,7 +37,7 @@ final class RelatedPostRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return ''; return '';
} }
@@ -51,7 +54,7 @@ final class RelatedPostRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'related-post'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -60,22 +63,6 @@ final class RelatedPostRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1; 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 private function getVisibilityClass(array $data): ?string
{ {
$showDesktop = $data['visibility']['show_on_desktop'] ?? true; $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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
/** /**
* SocialShareRenderer - Renderiza botones de compartir en redes sociales * SocialShareRenderer - Renderiza botones de compartir en redes sociales
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class SocialShareRenderer implements RendererInterface final class SocialShareRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'social-share';
private const NETWORKS = [ private const NETWORKS = [
'facebook' => [ 'facebook' => [
'field' => 'show_facebook', 'field' => 'show_facebook',
@@ -84,7 +87,7 @@ final class SocialShareRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return ''; return '';
} }
@@ -96,7 +99,7 @@ final class SocialShareRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'social-share'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -105,22 +108,6 @@ final class SocialShareRenderer implements RendererInterface
return $value === true || $value === '1' || $value === 1; 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 private function generateCSS(array $data): string
{ {
$colors = $data['colors'] ?? []; $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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use DOMDocument; use DOMDocument;
use DOMXPath; use DOMXPath;
@@ -30,6 +31,8 @@ use DOMXPath;
*/ */
final class TableOfContentsRenderer implements RendererInterface final class TableOfContentsRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'table-of-contents';
private array $headingCounter = []; private array $headingCounter = [];
public function __construct( public function __construct(
@@ -44,7 +47,7 @@ final class TableOfContentsRenderer implements RendererInterface
return ''; return '';
} }
if (!$this->shouldShowOnCurrentPage($data)) { if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
return ''; return '';
} }
@@ -63,7 +66,7 @@ final class TableOfContentsRenderer implements RendererInterface
public function supports(string $componentType): bool public function supports(string $componentType): bool
{ {
return $componentType === 'table-of-contents'; return $componentType === self::COMPONENT_NAME;
} }
private function isEnabled(array $data): bool private function isEnabled(array $data): bool
@@ -71,22 +74,6 @@ final class TableOfContentsRenderer implements RendererInterface
return ($data['visibility']['is_enabled'] ?? false) === true; 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 private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
{ {
if (!$desktop && !$mobile) { if (!$desktop && !$mobile) {
@@ -124,8 +111,23 @@ final class TableOfContentsRenderer implements RendererInterface
return []; return [];
} }
// Intentar primero con contenido filtrado (respeta shortcodes, etc.)
$content = apply_filters('the_content', $post->post_content); $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(); $dom = new DOMDocument();
libxml_use_internal_errors(true); libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content); $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\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
/** /**
* Class TopNotificationBarRenderer * Class TopNotificationBarRenderer
@@ -34,6 +36,8 @@ use ROITheme\Shared\Domain\Entities\Component;
*/ */
final class TopNotificationBarRenderer implements RendererInterface final class TopNotificationBarRenderer implements RendererInterface
{ {
private const COMPONENT_NAME = 'top-notification-bar';
/** /**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS * @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
*/ */
@@ -54,7 +58,12 @@ final class TopNotificationBarRenderer implements RendererInterface
} }
// Validar visibilidad por página // 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 ''; return '';
} }
@@ -78,7 +87,7 @@ final class TopNotificationBarRenderer implements RendererInterface
*/ */
public function supports(string $componentType): bool 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; 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 * Verificar si el componente fue dismissed por el usuario
* *

View File

@@ -110,3 +110,14 @@
transform: rotate(360deg); 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", "component_name": "adsense-placement",
"version": "1.3.0", "version": "1.6.0",
"description": "Control de AdSense y Google Analytics - Con Anchor y Vignette Ads", "description": "Control de AdSense y Google Analytics - Con In-Content Ads Avanzado",
"groups": { "groups": {
"visibility": { "visibility": {
"label": "Activacion", "label": "Activacion",
@@ -113,10 +113,182 @@
} }
} }
}, },
"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": { "behavior": {
"label": "Ubicaciones en Posts", "label": "Ubicaciones en Posts",
"priority": 70, "priority": 70,
"fields": { "fields": {
"javascript_first_mode": {
"type": "boolean",
"label": "JavaScript-First Mode (Cache Compatible)",
"default": false,
"editable": true,
"description": "Mueve decisiones de visibilidad de PHP a JavaScript para compatibilidad con cache de pagina. Permite personalizacion por usuario en sitios cacheados."
},
"post_top_enabled": { "post_top_enabled": {
"type": "boolean", "type": "boolean",
"label": "Despues de Featured Image", "label": "Despues de Featured Image",
@@ -258,6 +430,41 @@
"700": "700px (Debajo del fold)" "700": "700px (Debajo del fold)"
}, },
"description": "Distancia vertical desde el top del viewport" "description": "Distancia vertical desde el top del viewport"
},
"lazy_loading_enabled": {
"type": "boolean",
"label": "Lazy Loading de Anuncios",
"default": true,
"editable": true,
"description": "Cargar anuncios individualmente al entrar al viewport (mejora fill rate)"
},
"lazy_rootmargin": {
"type": "select",
"label": "Pre-carga (px antes del viewport)",
"default": "200",
"editable": true,
"options": {
"0": "0px (sin pre-carga)",
"100": "100px",
"200": "200px (recomendado)",
"300": "300px",
"400": "400px",
"500": "500px"
},
"description": "Pixeles de anticipacion para iniciar carga de anuncio"
},
"lazy_fill_timeout": {
"type": "select",
"label": "Timeout de llenado (ms)",
"default": "5000",
"editable": true,
"options": {
"3000": "3 segundos",
"5000": "5 segundos (recomendado)",
"7000": "7 segundos",
"10000": "10 segundos"
},
"description": "Tiempo maximo para esperar contenido de Google antes de ocultar slot"
} }
} }
}, },
@@ -437,6 +644,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": { "layout": {
"label": "Ubicaciones Archivos/Globales", "label": "Ubicaciones Archivos/Globales",
"priority": 80, "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, "default": true,
"editable": true, "editable": true,
"description": "Muestra el componente en pantallas < 992px" "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"
} }
} }
}, },

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